[ Widget Preview ] Add experimental support for web-based widget preview environment (#163154)

`flutter widget-preview start --web` will cause the widget preview
scaffold to be run as a Flutter Web application using experimental hot
reload support. This will eventually be the default, with the desktop
environment being put behind a flag for use as a fallback under the
assumption that the desktop environment will be removed in the future.
This commit is contained in:
Ben Konyi
2025-02-14 15:16:02 -05:00
committed by GitHub
parent 3e219d1cd8
commit 6e7e36fdd3
5 changed files with 110 additions and 32 deletions

View File

@@ -4,6 +4,7 @@
import 'package:args/args.dart';
import 'package:meta/meta.dart';
import 'package:package_config/package_config.dart';
import '../base/common.dart';
import '../base/deferred_component.dart';
@@ -118,17 +119,36 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
required this.os,
}) {
addPubOptions();
argParser.addFlag(
kLaunchPreviewer,
defaultsTo: true,
help: 'Launches the widget preview environment.',
// Should only be used for testing.
hide: !verboseHelp,
);
argParser
..addFlag(
kLaunchPreviewer,
defaultsTo: true,
help: 'Launches the widget preview environment.',
// Should only be used for testing.
hide: !verboseHelp,
)
..addFlag(
kUseFlutterWeb,
help: 'Launches the widget preview environment using Flutter Web.',
hide: !verboseHelp,
)
..addFlag(
kHeadlessWeb,
help: 'Launches Chrome in headless mode for testing.',
hide: !verboseHelp,
);
}
static const String kWidgetPreviewScaffoldName = 'widget_preview_scaffold';
static const String kLaunchPreviewer = 'launch-previewer';
static const String kUseFlutterWeb = 'web';
static const String kHeadlessWeb = 'headless-web';
@override
Future<Set<DevelopmentArtifact>> get requiredArtifacts async => const <DevelopmentArtifact>{
// Ensure the Flutter Web SDK is installed.
DevelopmentArtifact.web,
};
@override
String get description => 'Starts the widget preview environment.';
@@ -138,6 +158,8 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
final bool verboseHelp;
bool get isWeb => boolArg(kUseFlutterWeb);
@override
final FileSystem fs;
@@ -188,9 +210,10 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
titleCaseProjectName: 'Widget Preview Scaffold',
flutterRoot: Cache.flutterRoot!,
dartSdkVersionBounds: '^${cache.dartSdkBuild}',
linux: platform.isLinux,
macos: platform.isMacOS,
windows: platform.isWindows,
linux: platform.isLinux && !isWeb,
macos: platform.isMacOS && !isWeb,
windows: platform.isWindows && !isWeb,
web: isWeb,
),
overwrite: true,
generateMetadata: false,
@@ -238,6 +261,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
}
void onChangeDetected(PreviewMapping previews) {
_previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(previews);
logger.printStatus('Triggering reload based on change to preview set: $previews');
_widgetPreviewApp?.restart();
}
@@ -250,9 +274,10 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
Future<void> initialBuild({required FlutterProject widgetPreviewScaffoldProject}) async {
// TODO(bkonyi): handle error case where desktop device isn't enabled.
await widgetPreviewScaffoldProject.ensureReadyForPlatformSpecificTooling(
linuxPlatform: platform.isLinux,
macOSPlatform: platform.isMacOS,
windowsPlatform: platform.isWindows,
linuxPlatform: platform.isLinux && !isWeb,
macOSPlatform: platform.isMacOS && !isWeb,
windowsPlatform: platform.isWindows && !isWeb,
webPlatform: isWeb,
);
// Generate initial package_config.json, otherwise the build will fail.
@@ -263,6 +288,10 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
outputMode: PubOutputMode.summaryOnly,
);
if (isWeb) {
return;
}
// WARNING: this log message is used by test/integration.shard/widget_preview_test.dart
logger.printStatus('Performing initial build of the Widget Preview Scaffold...');
@@ -362,9 +391,12 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
// We launch from a prebuilt widget preview scaffold instance to reduce launch times after
// the first run.
final File prebuiltApplicationBinary = fs.file(
prebuiltApplicationBinaryPath(widgetPreviewScaffoldProject: widgetPreviewScaffoldProject),
);
File? prebuiltApplicationBinary;
if (!isWeb) {
prebuiltApplicationBinary = fs.file(
prebuiltApplicationBinaryPath(widgetPreviewScaffoldProject: widgetPreviewScaffoldProject),
);
}
const String? kEmptyRoute = null;
const bool kEnableHotReload = true;
@@ -378,12 +410,20 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
BuildMode.debug,
null,
treeShakeIcons: false,
extraFrontEndOptions:
isWeb ? <String>['--dartdevc-canary', '--dartdevc-module-format=ddc'] : null,
packageConfigPath: widgetPreviewScaffoldProject.packageConfig.path,
packageConfig: PackageConfig.parseBytes(
widgetPreviewScaffoldProject.packageConfig.readAsBytesSync(),
widgetPreviewScaffoldProject.packageConfig.uri,
),
),
webEnableExposeUrl: false, // TODO(bkonyi): verify
webRunHeadless: boolArg(kHeadlessWeb),
),
kEnableHotReload, // hot mode
applicationBinary: prebuiltApplicationBinary,
trackWidgetCreation: false,
trackWidgetCreation: true,
projectRootPath: widgetPreviewScaffoldProject.directory.path,
);
} on Exception catch (error) {

View File

@@ -71,7 +71,9 @@ class WebBuilder {
)).any((Plugin p) => p.platforms.containsKey(WebPlugin.kConfigKey));
final Directory outputDirectory =
outputDirectoryPath == null
? _fileSystem.directory(getWebBuildDirectory())
? _fileSystem.directory(
_fileSystem.path.join(flutterProject.directory.path, getWebBuildDirectory()),
)
: _fileSystem.directory(outputDirectoryPath);
outputDirectory.createSync(recursive: true);
@@ -89,7 +91,7 @@ class WebBuilder {
final BuildResult result = await _buildSystem.build(
globals.buildTargets.webServiceWorker(_fileSystem, compilerConfigs),
Environment(
projectDir: _fileSystem.currentDirectory,
projectDir: flutterProject.directory,
outputDir: outputDirectory,
buildDir: flutterProject.directory
.childDirectory('.dart_tool')

View File

@@ -3,8 +3,7 @@
// found in the LICENSE file.
import 'src/widget_preview_rendering.dart';
import 'src/generated_preview.dart';
Future<void> main() async {
await mainImpl(previewsProvider: previews);
await mainImpl();
}

View File

@@ -2,11 +2,13 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'controls.dart';
import 'generated_preview.dart';
import 'utils.dart';
class WidgetPreviewWidget extends StatefulWidget {
@@ -289,6 +291,10 @@ class PreviewAssetBundle extends PlatformAssetBundle {
@override
Future<ImmutableBuffer> loadBuffer(String key) async {
if (kIsWeb) {
final ByteData bytes = await load(key);
return ImmutableBuffer.fromUint8List(Uint8List.sublistView(bytes));
}
return await ImmutableBuffer.fromAsset(
key.startsWith(_kPackagesPrefix) ? key : '../../$key',
);
@@ -300,24 +306,18 @@ class PreviewAssetBundle extends PlatformAssetBundle {
/// We don't actually define this as `main` to avoid copying this file into
/// the preview scaffold project which prevents us from being able to use hot
/// restart to iterate on this file.
Future<void> mainImpl({
required List<WidgetPreview> Function() previewsProvider,
}) async {
Future<void> mainImpl() async {
runApp(
_WidgetPreviewScaffold(
previewsProvider: previewsProvider,
),
_WidgetPreviewScaffold(),
);
}
class _WidgetPreviewScaffold extends StatelessWidget {
const _WidgetPreviewScaffold({required this.previewsProvider});
final List<WidgetPreview> Function() previewsProvider;
const _WidgetPreviewScaffold();
@override
Widget build(BuildContext context) {
final List<WidgetPreview> previewList = previewsProvider();
final List<WidgetPreview> previewList = previews();
Widget previewView;
if (previewList.isEmpty) {
previewView = const Column(

View File

@@ -7,6 +7,7 @@ import 'dart:convert';
import 'package:file/file.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/commands/widget_preview.dart';
import 'package:process/process.dart';
import '../src/common.dart';
@@ -26,6 +27,12 @@ const List<String> subsequentLaunchMessages = <String>[
'Done loading previews.',
];
const List<String> firstLaunchMessagesWeb = <String>[
'Creating widget preview scaffolding at:',
'Loading previews into the Widget Preview Scaffold...',
'Done loading previews.',
];
void main() {
late Directory tempDir;
Process? process;
@@ -43,13 +50,21 @@ void main() {
tryToDelete(tempDir);
});
Future<void> runWidgetPreview({required List<String> expectedMessages}) async {
Future<void> runWidgetPreview({
required List<String> expectedMessages,
bool useWeb = false,
}) async {
expect(expectedMessages, isNotEmpty);
int i = 0;
process = await processManager.start(<String>[
flutterBin,
'widget-preview',
'start',
'--verbose',
if (useWeb) ...<String>[
'--${WidgetPreviewStartCommand.kUseFlutterWeb}',
'--${WidgetPreviewStartCommand.kHeadlessWeb}',
],
], workingDirectory: tempDir.path);
final Completer<void> completer = Completer<void>();
@@ -70,6 +85,16 @@ void main() {
printOnFailure('STDERR: $msg');
});
unawaited(
process!.exitCode.then((int exitCode) {
if (completer.isCompleted) {
return;
}
completer.completeError(
TestFailure('The widget previewer exited unexpectedly (exit code: $exitCode)'),
);
}),
);
await completer.future;
process!.kill();
process = null;
@@ -80,6 +105,10 @@ void main() {
await runWidgetPreview(expectedMessages: firstLaunchMessages);
});
testWithoutContext('web smoke test', () async {
await runWidgetPreview(expectedMessages: firstLaunchMessagesWeb, useWeb: true);
});
testWithoutContext('does not rebuild project on subsequent runs', () async {
// The first run of 'flutter widget-preview start' should generate a new preview scaffold and
// pre-build the application.
@@ -88,5 +117,13 @@ void main() {
// We shouldn't regenerate the scaffold after the initial run.
await runWidgetPreview(expectedMessages: subsequentLaunchMessages);
});
testWithoutContext('does not recreate project on subsequent --web runs', () async {
// The first run of 'flutter widget-preview start --web' should generate a new preview scaffold
await runWidgetPreview(expectedMessages: firstLaunchMessagesWeb, useWeb: true);
// We shouldn't regenerate the scaffold after the initial run.
await runWidgetPreview(expectedMessages: subsequentLaunchMessages, useWeb: true);
});
});
}