From 7e32a77210b5f22c1d2255ca1c6b4474a8f23fa9 Mon Sep 17 00:00:00 2001 From: Nicholas Shahan Date: Mon, 2 Jun 2025 16:13:50 -0700 Subject: [PATCH] [flutter_tools] Enable hot reload on the web (#169174) [flutter_tools] Enable hot reload on the web Update the defaults so hot reload is enabled on web development builds by default. This enables the use of a new module representation in the compiled JavaScript. Passing `--no-web-experimental-hot-reload` will disable the ability to hot reload and return to the AMD JavaScript module representation. This change avoids enabling hot reload in the flutter drive tests since they rely on `-d web-server` which has known startup issues. When https://github.com/dart-lang/sdk/issues/60289 is resolved it should be safe to enable hot reload by default for the `flutter drive` tests. Fixes: https://github.com/flutter/flutter/issues/167510 --- dev/bots/suite_runners/run_web_tests.dart | 3 + .../flutter_tools/lib/src/build_info.dart | 36 +++++- .../lib/src/commands/widget_preview.dart | 1 - .../lib/src/isolated/resident_web_runner.dart | 7 +- .../lib/src/resident_runner.dart | 8 +- .../lib/src/runner/flutter_command.dart | 9 +- .../lib/src/test/flutter_web_platform.dart | 4 +- .../hermetic/flutter_web_platform_test.dart | 1 + .../resident_web_runner_test.dart | 122 ++++++++++++++++-- 9 files changed, 164 insertions(+), 27 deletions(-) diff --git a/dev/bots/suite_runners/run_web_tests.dart b/dev/bots/suite_runners/run_web_tests.dart index 58101f023be..6b56ceeca9a 100644 --- a/dev/bots/suite_runners/run_web_tests.dart +++ b/dev/bots/suite_runners/run_web_tests.dart @@ -360,6 +360,8 @@ class WebTestsSuite { '--browser-name=chrome', '-d', 'web-server', + // TODO(nshahan): Remove when web-server can run with hot reload, https://github.com/dart-lang/sdk/issues/60289. + if (buildMode == 'debug') '--no-web-experimental-hot-reload', '--$buildMode', if (webRenderer == 'skwasm') ...[ // See: WebRendererMode.dartDefines[skwasm] @@ -487,6 +489,7 @@ class WebTestsSuite { '--browser-name=chrome', '-d', 'web-server', + if (buildMode == 'debug') '--no-web-experimental-hot-reload', '--$buildMode', ], workingDirectory: testAppDirectory, diff --git a/packages/flutter_tools/lib/src/build_info.dart b/packages/flutter_tools/lib/src/build_info.dart index a4e941a9c59..51f8008c464 100644 --- a/packages/flutter_tools/lib/src/build_info.dart +++ b/packages/flutter_tools/lib/src/build_info.dart @@ -17,6 +17,7 @@ import 'base/os.dart'; import 'base/utils.dart'; import 'convert.dart'; import 'globals.dart' as globals; +import 'runner/flutter_command.dart' show FlutterOptions; /// Whether icon font subsetting is enabled by default. const bool kIconTreeShakerEnabledDefault = true; @@ -50,6 +51,7 @@ class BuildInfo { this.assumeInitializeFromDillUpToDate = false, this.buildNativeAssets = true, this.useLocalCanvasKit = false, + this.webEnableHotReload = false, }) : extraFrontEndOptions = extraFrontEndOptions ?? const [], extraGenSnapshotOptions = extraGenSnapshotOptions ?? const [], fileSystemRoots = fileSystemRoots ?? const [], @@ -177,6 +179,9 @@ class BuildInfo { /// If set, web builds will use the locally built CanvasKit instead of using the CDN final bool useLocalCanvasKit; + /// If set, web builds with DDC will run with support for hot reload. + final bool webEnableHotReload; + /// Can be used when the actual information is not needed. static const BuildInfo dummy = BuildInfo( BuildMode.debug, @@ -259,13 +264,36 @@ class BuildInfo { /// The module system DDC is targeting, or null if not using DDC or the /// associated flag isn't present. // TODO(markzipan): delete this when DDC's AMD module system is deprecated, https://github.com/flutter/flutter/issues/142060. - DdcModuleFormat? get ddcModuleFormat => - _ddcModuleFormatAndCanaryFeaturesFromFrontEndArgs(extraFrontEndOptions).ddcModuleFormat; + DdcModuleFormat get ddcModuleFormat { + final DdcModuleFormat moduleFormat = + webEnableHotReload ? DdcModuleFormat.ddc : DdcModuleFormat.amd; + final DdcModuleFormat? parsedFormat = + _ddcModuleFormatAndCanaryFeaturesFromFrontEndArgs(extraFrontEndOptions).ddcModuleFormat; + if (parsedFormat != null && moduleFormat != parsedFormat) { + throw Exception( + 'Unsupported option combination:\n' + '${FlutterOptions.kWebExperimentalHotReload}: $webEnableHotReload\n' + '${FlutterOptions.kExtraFrontEndOptions}: --dartdevc-module-format=${parsedFormat.name}', + ); + } + return moduleFormat; + } /// Whether to enable canary features when using DDC, or null if not using /// DDC or the associated flag isn't present. - bool? get canaryFeatures => - _ddcModuleFormatAndCanaryFeaturesFromFrontEndArgs(extraFrontEndOptions).canaryFeatures; + bool get canaryFeatures { + final bool canaryEnabled = webEnableHotReload; + final bool? parsedCanary = + _ddcModuleFormatAndCanaryFeaturesFromFrontEndArgs(extraFrontEndOptions).canaryFeatures; + if (parsedCanary != null && canaryEnabled != parsedCanary) { + throw Exception( + 'Unsupported option combination:\n' + '${FlutterOptions.kWebExperimentalHotReload}: $webEnableHotReload\n' + '${FlutterOptions.kExtraFrontEndOptions}: --dartdevc-canary=$parsedCanary', + ); + } + return canaryEnabled; + } /// Convert to a structured string encoded structure appropriate for usage /// in build system [Environment.defines]. diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index 62a6eb0e29b..b5683a9ea28 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -375,7 +375,6 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C // extensions will work with Flutter web embedded in VSCode without a Chrome debugger // connection. dartDefines: ['$kWidgetPreviewDtdUriEnvVar=${_dtdService.dtdUri}'], - extraFrontEndOptions: ['--dartdevc-canary', '--dartdevc-module-format=ddc'], packageConfigPath: widgetPreviewScaffoldProject.packageConfig.path, packageConfig: PackageConfig.parseBytes( widgetPreviewScaffoldProject.packageConfig.readAsBytesSync(), diff --git a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart index 5c02901f572..4b4a48699b2 100644 --- a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart +++ b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart @@ -167,12 +167,13 @@ class ResidentWebRunner extends ResidentRunner { @override bool get reloadIsRestart => + debuggingOptions.webUseWasm || // Web behavior when not using the DDC library bundle format is to restart // when a reload is issued. We can't use `canHotReload` to signal this // since we still want a reload command to succeed, but to do a hot // restart. debuggingOptions.buildInfo.ddcModuleFormat != DdcModuleFormat.ddc || - debuggingOptions.buildInfo.canaryFeatures != true; + !debuggingOptions.buildInfo.canaryFeatures; @override bool get supportsDetach => stopAppDuringCleanup; @@ -321,7 +322,7 @@ Please provide a valid TCP port (an integer between 0 and 65535, inclusive). chromiumLauncher: _chromiumLauncher, nativeNullAssertions: debuggingOptions.nativeNullAssertions, ddcModuleSystem: debuggingOptions.buildInfo.ddcModuleFormat == DdcModuleFormat.ddc, - canaryFeatures: debuggingOptions.buildInfo.canaryFeatures ?? false, + canaryFeatures: debuggingOptions.buildInfo.canaryFeatures, webRenderer: debuggingOptions.webRenderer, isWasm: debuggingOptions.webUseWasm, useLocalCanvasKit: debuggingOptions.buildInfo.useLocalCanvasKit, @@ -420,7 +421,7 @@ Please provide a valid TCP port (an integer between 0 and 65535, inclusive). final DateTime start = _systemClock.now(); final Status status; if (debuggingOptions.buildInfo.ddcModuleFormat != DdcModuleFormat.ddc || - debuggingOptions.buildInfo.canaryFeatures == false) { + !debuggingOptions.buildInfo.canaryFeatures) { // Triggering hot reload performed hot restart for the old module formats // historically. Keep that behavior and only perform hot reload when the // new module format is used. diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart index 7d87339a6d1..a7b7bb1d754 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -114,6 +114,12 @@ class FlutterDevice { globals.artifacts!.getHostArtifact(HostArtifact.webPlatformKernelFolder).path, platformDillName, ); + final List extraFrontEndOptions = [ + ...buildInfo.extraFrontEndOptions, + if (buildInfo.webEnableHotReload) + // These flags are only valid to be passed when compiling with DDC. + ...['--dartdevc-canary', '--dartdevc-module-format=ddc'], + ]; generator = ResidentCompiler( globals.artifacts!.getHostArtifact(HostArtifact.flutterWebSdk).path, @@ -133,7 +139,7 @@ class FlutterDevice { assumeInitializeFromDillUpToDate: buildInfo.assumeInitializeFromDillUpToDate, targetModel: TargetModel.dartdevc, frontendServerStarterPath: buildInfo.frontendServerStarterPath, - extraFrontEndOptions: buildInfo.extraFrontEndOptions, + extraFrontEndOptions: extraFrontEndOptions, platformDill: globals.fs.file(platformDillPath).absolute.uri.toString(), dartDefines: buildInfo.dartDefines, librariesSpec: diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 02ad760ef5e..ce73687e364 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -369,6 +369,7 @@ abstract class FlutterCommand extends Command { argParser.addFlag( FlutterOptions.kWebExperimentalHotReload, help: 'Enables new module format that supports hot reload.', + defaultsTo: true, hide: !verboseHelp, ); argParser.addOption( @@ -1325,10 +1326,9 @@ abstract class FlutterCommand extends Command { } // TODO(natebiggs): Delete this when new DDC module system is the default. - if (argParser.options.containsKey(FlutterOptions.kWebExperimentalHotReload) && - boolArg(FlutterOptions.kWebExperimentalHotReload)) { - extraFrontEndOptions.addAll(['--dartdevc-canary', '--dartdevc-module-format=ddc']); - } + final bool webEnableHotReload = + argParser.options.containsKey(FlutterOptions.kWebExperimentalHotReload) && + boolArg(FlutterOptions.kWebExperimentalHotReload); String? codeSizeDirectory; if (argParser.options.containsKey(FlutterOptions.kAnalyzeSize) && @@ -1457,6 +1457,7 @@ abstract class FlutterCommand extends Command { argParser.options.containsKey(FlutterOptions.kAssumeInitializeFromDillUpToDate) && boolArg(FlutterOptions.kAssumeInitializeFromDillUpToDate), useLocalCanvasKit: useLocalCanvasKit, + webEnableHotReload: webEnableHotReload, ); } diff --git a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart index aea3c00b93d..48ff0496934 100644 --- a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart +++ b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart @@ -283,7 +283,7 @@ class FlutterWebPlatform extends PlatformPlugin { // TODO(srujzs): Remove this assertion when the library bundle format is // supported without canary mode. if (buildInfo.ddcModuleFormat == DdcModuleFormat.ddc) { - assert(buildInfo.canaryFeatures ?? true); + assert(buildInfo.canaryFeatures); } final Map dartSdkArtifactMap = buildInfo.ddcModuleFormat == DdcModuleFormat.ddc @@ -296,7 +296,7 @@ class FlutterWebPlatform extends PlatformPlugin { // TODO(srujzs): Remove this assertion when the library bundle format is // supported without canary mode. if (buildInfo.ddcModuleFormat == DdcModuleFormat.ddc) { - assert(buildInfo.canaryFeatures ?? true); + assert(buildInfo.canaryFeatures); } final Map dartSdkArtifactMap = buildInfo.ddcModuleFormat == DdcModuleFormat.ddc diff --git a/packages/flutter_tools/test/commands.shard/hermetic/flutter_web_platform_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/flutter_web_platform_test.dart index 94f72f49ba1..75146b19884 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/flutter_web_platform_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/flutter_web_platform_test.dart @@ -133,6 +133,7 @@ void main() { packageConfigPath: '.dart_tool/package_config.json', treeShakeIcons: false, extraFrontEndOptions: ['--dartdevc-module-format=ddc', '--canary'], + webEnableHotReload: true, ), webMemoryFS: WebMemoryFS(), fileSystem: fileSystem, diff --git a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart index 97859cd1536..d3fcf058713 100644 --- a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart @@ -735,7 +735,8 @@ name: my_app trackWidgetCreation: true, treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', - // Hot reload only supported with these flags for now. + // TODO(nshahan): Remove when hot reload can no longer be disabled. + webEnableHotReload: true, extraFrontEndOptions: kDdcLibraryBundleFlags, ), ), @@ -842,7 +843,8 @@ name: my_app trackWidgetCreation: true, treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', - // Hot reload only supported with these flags for now. + // TODO(nshahan): Remove when hot reload can no longer be disabled. + webEnableHotReload: true, extraFrontEndOptions: kDdcLibraryBundleFlags, ), ), @@ -927,7 +929,8 @@ name: my_app trackWidgetCreation: true, treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', - // Hot reload only supported with these flags for now. + // TODO(nshahan): Remove when hot reload can no longer be disabled. + webEnableHotReload: true, extraFrontEndOptions: kDdcLibraryBundleFlags, ), webUseWasm: true, @@ -996,13 +999,14 @@ name: my_app // Test one extra config where `fullRestart` is false without the DDC library // bundle format - we should do a hot restart in this case because hot reload // is not available. - for (final (List flags, bool fullRestart) in <(List, bool)>[ - (kDdcLibraryBundleFlags, true), - ([], true), - ([], false), + for (final (bool webEnableHotReload, bool fullRestart) in <(bool, bool)>[ + (true, true), + (false, true), + (false, false), ]) { testUsingContext( - 'Can hot restart after attaching with flags: $flags fullRestart: $fullRestart', + 'Can hot restart after attaching with ' + 'webEnableHotReload: $webEnableHotReload fullRestart: $fullRestart', () async { final BufferLogger logger = BufferLogger.test(); final ResidentRunner residentWebRunner = setUpResidentRunner( @@ -1016,7 +1020,8 @@ name: my_app trackWidgetCreation: true, treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', - extraFrontEndOptions: flags, + extraFrontEndOptions: webEnableHotReload ? kDdcLibraryBundleFlags : const [], + webEnableHotReload: webEnableHotReload, ), ), ); @@ -1242,8 +1247,9 @@ name: my_app }, ); + // TODO(nshahan): Delete this test case when hot reload can no longer be disabled. testUsingContext( - 'Fails non-fatally on vmservice response error for hot restart', + 'Fails non-fatally on vmservice response error for hot restart (legacy default case)', () async { final ResidentRunner residentWebRunner = setUpResidentRunner(flutterDevice); fakeVmServiceHost = FakeVmServiceHost( @@ -1261,6 +1267,8 @@ name: my_app unawaited(residentWebRunner.run(connectionInfoCompleter: connectionInfoCompleter)); await connectionInfoCompleter.future; + // Historically the .restart() would perform a hot restart even without + // passing fullRestart: true. final OperationResult result = await residentWebRunner.restart(); expect(result.code, 0); @@ -1272,8 +1280,9 @@ name: my_app }, ); + // TODO(nshahan): Delete this test case when hot reload can no longer be disabled. testUsingContext( - 'Fails fatally on Vm Service error response', + 'Fails fatally on Vm Service error response (legacy default case)', () async { final ResidentRunner residentWebRunner = setUpResidentRunner(flutterDevice); fakeVmServiceHost = FakeVmServiceHost( @@ -1303,6 +1312,94 @@ name: my_app }, ); + for (final bool webEnableHotReload in [true, false]) { + testUsingContext( + 'Fails non-fatally on vmservice response error for hot restart with webEnableHotReload: $webEnableHotReload', + () async { + final ResidentRunner residentWebRunner = setUpResidentRunner( + flutterDevice, + debuggingOptions: DebuggingOptions.enabled( + BuildInfo( + BuildMode.debug, + null, + trackWidgetCreation: true, + treeShakeIcons: false, + packageConfigPath: '.dart_tool/package_config.json', + webEnableHotReload: webEnableHotReload, + extraFrontEndOptions: webEnableHotReload ? kDdcLibraryBundleFlags : [], + ), + ), + ); + fakeVmServiceHost = FakeVmServiceHost( + requests: [ + ...kAttachExpectations, + const FakeVmServiceRequest( + method: kHotRestartServiceName, + jsonResponse: {'type': 'Failed'}, + ), + ], + ); + setupMocks(); + final Completer connectionInfoCompleter = + Completer(); + unawaited(residentWebRunner.run(connectionInfoCompleter: connectionInfoCompleter)); + await connectionInfoCompleter.future; + + final OperationResult result = await residentWebRunner.restart(fullRestart: true); + + expect(result.code, 0); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + Pub: ThrowingPub.new, + }, + ); + + testUsingContext( + 'Fails fatally on Vm Service error response with webEnableHotReload: $webEnableHotReload', + () async { + final ResidentRunner residentWebRunner = setUpResidentRunner( + flutterDevice, + debuggingOptions: DebuggingOptions.enabled( + BuildInfo( + BuildMode.debug, + null, + trackWidgetCreation: true, + treeShakeIcons: false, + packageConfigPath: '.dart_tool/package_config.json', + webEnableHotReload: webEnableHotReload, + extraFrontEndOptions: webEnableHotReload ? kDdcLibraryBundleFlags : [], + ), + ), + ); + fakeVmServiceHost = FakeVmServiceHost( + requests: [ + ...kAttachExpectations, + FakeVmServiceRequest( + method: kHotRestartServiceName, + // Failed response, + error: FakeRPCError(code: vm_service.RPCErrorKind.kInternalError.code), + ), + ], + ); + setupMocks(); + final Completer connectionInfoCompleter = + Completer(); + unawaited(residentWebRunner.run(connectionInfoCompleter: connectionInfoCompleter)); + await connectionInfoCompleter.future; + final OperationResult result = await residentWebRunner.restart(fullRestart: true); + + expect(result.code, 1); + expect(result.message, contains(vm_service.RPCErrorKind.kInternalError.code.toString())); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + Pub: ThrowingPub.new, + }, + ); + } testUsingContext( 'printHelp without details shows only hot restart help message', () async { @@ -1335,7 +1432,8 @@ name: my_app trackWidgetCreation: true, treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', - // Hot reload only supported with these flags for now. + // TODO(nshahan): Remove when hot reload can no longer be disabled. + webEnableHotReload: true, extraFrontEndOptions: kDdcLibraryBundleFlags, ), ),