From 6e7e36fdd3daa2f1bd8da0b8af0eb64c16584661 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 14 Feb 2025 15:16:02 -0500 Subject: [PATCH] [ 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. --- .../lib/src/commands/widget_preview.dart | 74 ++++++++++++++----- .../flutter_tools/lib/src/web/compile.dart | 6 +- .../lib/main.dart.tmpl | 3 +- .../src/widget_preview_rendering.dart.tmpl | 20 ++--- .../widget_preview_test.dart | 39 +++++++++- 5 files changed, 110 insertions(+), 32 deletions(-) diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index 79394cce20..059fd01eb4 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -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> get requiredArtifacts async => const { + // 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 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 ? ['--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) { diff --git a/packages/flutter_tools/lib/src/web/compile.dart b/packages/flutter_tools/lib/src/web/compile.dart index de4c701b5c..d32eea4f58 100644 --- a/packages/flutter_tools/lib/src/web/compile.dart +++ b/packages/flutter_tools/lib/src/web/compile.dart @@ -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') diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/main.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/main.dart.tmpl index 8044f77114..0dd047b6ad 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/main.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/main.dart.tmpl @@ -3,8 +3,7 @@ // found in the LICENSE file. import 'src/widget_preview_rendering.dart'; -import 'src/generated_preview.dart'; Future main() async { - await mainImpl(previewsProvider: previews); + await mainImpl(); } diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl index 11bcdf419d..42a9507c7e 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl @@ -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 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 mainImpl({ - required List Function() previewsProvider, -}) async { +Future mainImpl() async { runApp( - _WidgetPreviewScaffold( - previewsProvider: previewsProvider, - ), + _WidgetPreviewScaffold(), ); } class _WidgetPreviewScaffold extends StatelessWidget { - const _WidgetPreviewScaffold({required this.previewsProvider}); - - final List Function() previewsProvider; + const _WidgetPreviewScaffold(); @override Widget build(BuildContext context) { - final List previewList = previewsProvider(); + final List previewList = previews(); Widget previewView; if (previewList.isEmpty) { previewView = const Column( diff --git a/packages/flutter_tools/test/integration.shard/widget_preview_test.dart b/packages/flutter_tools/test/integration.shard/widget_preview_test.dart index 6ebe0ea6e3..77f9fb7133 100644 --- a/packages/flutter_tools/test/integration.shard/widget_preview_test.dart +++ b/packages/flutter_tools/test/integration.shard/widget_preview_test.dart @@ -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 subsequentLaunchMessages = [ 'Done loading previews.', ]; +const List firstLaunchMessagesWeb = [ + '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 runWidgetPreview({required List expectedMessages}) async { + Future runWidgetPreview({ + required List expectedMessages, + bool useWeb = false, + }) async { expect(expectedMessages, isNotEmpty); int i = 0; process = await processManager.start([ flutterBin, 'widget-preview', 'start', + '--verbose', + if (useWeb) ...[ + '--${WidgetPreviewStartCommand.kUseFlutterWeb}', + '--${WidgetPreviewStartCommand.kHeadlessWeb}', + ], ], workingDirectory: tempDir.path); final Completer completer = Completer(); @@ -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); + }); }); }