[web] flutter.js initialization with ui.webOnlyWarmupEngine (#100177)
diff --git a/packages/flutter_tools/lib/src/build_system/targets/web.dart b/packages/flutter_tools/lib/src/build_system/targets/web.dart index e26b0b4..c38d357 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart
@@ -17,6 +17,7 @@ import '../../dart/package_map.dart'; import '../../globals.dart' as globals; import '../../project.dart'; +import '../../web/flutter_js.dart' as flutter_js; import '../build_system.dart'; import '../depfile.dart'; import '../exceptions.dart'; @@ -96,7 +97,7 @@ @override Future<void> build(Environment environment) async { final String? targetFile = environment.defines[kTargetFile]; - final bool hasPlugins = environment.defines[kHasWebPlugins] == 'true'; + final bool hasWebPlugins = environment.defines[kHasWebPlugins] == 'true'; final Uri importUri = environment.fileSystem.file(targetFile).absolute.uri; // TODO(zanderso): support configuration of this file. const String packageFile = '.packages'; @@ -120,48 +121,54 @@ // By construction, this will only be null if the .packages file does not // have an entry for the user's application or if the main file is // outside of the lib/ directory. - final String mainImport = packageConfig.toPackageUri(importUri)?.toString() + final String importedEntrypoint = packageConfig.toPackageUri(importUri)?.toString() ?? importUri.toString(); - String contents; - if (hasPlugins) { + String? generatedImport; + if (hasWebPlugins) { final Uri generatedUri = environment.projectDir .childDirectory('lib') .childFile('generated_plugin_registrant.dart') .absolute .uri; - final String generatedImport = packageConfig.toPackageUri(generatedUri)?.toString() + generatedImport = packageConfig.toPackageUri(generatedUri)?.toString() ?? generatedUri.toString(); - contents = ''' -// @dart=${languageVersion.major}.${languageVersion.minor} - -import 'dart:ui' as ui; - -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -import '$generatedImport'; -import '$mainImport' as entrypoint; - -Future<void> main() async { - registerPlugins(webPluginRegistrar); - await ui.webOnlyInitializePlatform(); - entrypoint.main(); -} -'''; - } else { - contents = ''' -// @dart=${languageVersion.major}.${languageVersion.minor} - -import 'dart:ui' as ui; - -import '$mainImport' as entrypoint; - -Future<void> main() async { - await ui.webOnlyInitializePlatform(); - entrypoint.main(); -} -'''; } + + final String contents = <String>[ + '// @dart=${languageVersion.major}.${languageVersion.minor}', + '// Flutter web bootstrap script for $importedEntrypoint.', + '', + "import 'dart:ui' as ui;", + "import 'dart:async';", + '', + "import '$importedEntrypoint' as entrypoint;", + if (hasWebPlugins) + "import 'package:flutter_web_plugins/flutter_web_plugins.dart';", + if (hasWebPlugins) + "import '$generatedImport';", + '', + 'typedef _UnaryFunction = dynamic Function(List<String> args);', + 'typedef _NullaryFunction = dynamic Function();', + '', + 'Future<void> main() async {', + ' await ui.webOnlyWarmupEngine(', + ' runApp: () {', + ' if (entrypoint.main is _UnaryFunction) {', + ' return (entrypoint.main as _UnaryFunction)(<String>[]);', + ' }', + ' return (entrypoint.main as _NullaryFunction)();', + ' },', + if (hasWebPlugins) ...<String>[ + ' registerPlugins: () {', + ' registerPlugins(webPluginRegistrar);', + ' },', + ], + ' );', + '}', + '', + ].join('\n'); + environment.buildDir.childFile('main.dart') .writeAsStringSync(contents); } @@ -449,6 +456,10 @@ final String targetPath = fileSystem.path.join(environment.outputDir.path, 'canvaskit', relativePath); file.copySync(targetPath); } + + // Write the flutter.js file + final File flutterJsFile = environment.outputDir.childFile('flutter.js'); + flutterJsFile.writeAsStringSync(flutter_js.generateFlutterJsFile()); } }
diff --git a/packages/flutter_tools/lib/src/isolated/devfs_web.dart b/packages/flutter_tools/lib/src/isolated/devfs_web.dart index e9fd1d0..b111111 100644 --- a/packages/flutter_tools/lib/src/isolated/devfs_web.dart +++ b/packages/flutter_tools/lib/src/isolated/devfs_web.dart
@@ -41,6 +41,7 @@ import '../web/bootstrap.dart'; import '../web/chrome.dart'; import '../web/compile.dart'; +import '../web/flutter_js.dart' as flutter_js; import '../web/memory_fs.dart'; typedef DwdsLauncher = Future<Dwds> Function({ @@ -809,6 +810,7 @@ 'stack_trace_mapper.js', stackTraceMapper.readAsBytesSync()); webAssetServer.writeFile( 'manifest.json', '{"info":"manifest not generated in run mode."}'); + webAssetServer.writeFile('flutter.js', flutter_js.generateFlutterJsFile()); webAssetServer.writeFile('flutter_service_worker.js', '// Service worker not loaded in run mode.'); webAssetServer.writeFile(
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 0ac5ce2..21c5bed 100644 --- a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart +++ b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart
@@ -473,13 +473,19 @@ 'typedef _UnaryFunction = dynamic Function(List<String> args);', 'typedef _NullaryFunction = dynamic Function();', 'Future<void> main() async {', - if (hasWebPlugins) - ' registerPlugins(webPluginRegistrar);', - ' await ui.webOnlyInitializePlatform();', - ' if (entrypoint.main is _UnaryFunction) {', - ' return (entrypoint.main as _UnaryFunction)(<String>[]);', - ' }', - ' return (entrypoint.main as _NullaryFunction)();', + ' await ui.webOnlyWarmupEngine(', + ' runApp: () {', + ' if (entrypoint.main is _UnaryFunction) {', + ' return (entrypoint.main as _UnaryFunction)(<String>[]);', + ' }', + ' return (entrypoint.main as _NullaryFunction)();', + ' },', + if (hasWebPlugins) ...<String>[ + ' registerPlugins: () {', + ' registerPlugins(webPluginRegistrar);', + ' },', + ], + ' );', '}', '', ].join('\n');
diff --git a/packages/flutter_tools/lib/src/web/flutter_js.dart b/packages/flutter_tools/lib/src/web/flutter_js.dart new file mode 100644 index 0000000..dfff827 --- /dev/null +++ b/packages/flutter_tools/lib/src/web/flutter_js.dart
@@ -0,0 +1,146 @@ +// 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. + + +/// Generates the flutter.js file. +/// +/// flutter.js should be completely static, so **do not use any parameter or +/// environment variable to generate this file**. +String generateFlutterJsFile() { + return ''' +// 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. + +/** + * This script installs service_worker.js to provide PWA functionality to + * application. For more information, see: + * https://developers.google.com/web/fundamentals/primers/service-workers + */ + +if (!_flutter) { + var _flutter = {}; +} +_flutter.loader = null; + +(function() { + "use strict"; + class FlutterLoader { + // TODO: Move the below methods to "#private" once supported by all the browsers + // we support. In the meantime, we use the "revealing module" pattern. + + // Watchdog to prevent injecting the main entrypoint multiple times. + _scriptLoaded = false; + + // Resolver for the pending promise returned by loadEntrypoint. + _didCreateEngineInitializerResolve = null; + + /** + * Initializes the main.dart.js with/without serviceWorker. + * @param {*} options + * @returns a Promise that will eventually resolve with an EngineInitializer, + * or will be rejected with the error caused by the loader. + */ + loadEntrypoint(options) { + const { + entrypointUrl = "main.dart.js", + serviceWorker, + } = (options || {}); + return this._loadWithServiceWorker(entrypointUrl, serviceWorker); + } + + /** + * Resolves the promise created by loadEntrypoint. Called by Flutter. + * Needs to be weirdly bound like it is, so "this" is preserved across + * the JS <-> Flutter jumps. + * @param {*} engineInitializer + */ + didCreateEngineInitializer = (function(engineInitializer) { + if (typeof this._didCreateEngineInitializerResolve != "function") { + console.warn("Do not call didCreateEngineInitializer by hand. Start with loadEntrypoint instead."); + } + this._didCreateEngineInitializerResolve(engineInitializer); + }).bind(this); + + _loadEntrypoint(entrypointUrl) { + if (this._scriptLoaded) { + return null; + } + + this._scriptLoaded = true; + + return new Promise((resolve, reject) => { + let scriptTag = document.createElement("script"); + scriptTag.src = entrypointUrl; + scriptTag.type = "application/javascript"; + this._didCreateEngineInitializerResolve = resolve; // Cache the resolve, so it can be called from Flutter. + scriptTag.addEventListener("error", reject); + document.body.append(scriptTag); + }); + } + + _waitForServiceWorkerActivation(serviceWorker, entrypointUrl) { + if (!serviceWorker) return; + return new Promise((resolve, _) => { + serviceWorker.addEventListener("statechange", () => { + if (serviceWorker.state == "activated") { + console.log("Installed new service worker."); + resolve(this._loadEntrypoint(entrypointUrl)); + } + }); + }); + } + + _loadWithServiceWorker(entrypointUrl, serviceWorkerOptions) { + if (!("serviceWorker" in navigator) || serviceWorkerOptions == null) { + console.warn("Service worker not supported (or configured). Falling back to plain <script> tag.", serviceWorkerOptions); + return this._loadEntrypoint(entrypointUrl); + } + + const { + serviceWorkerVersion, + timeoutMillis = 4000, + } = serviceWorkerOptions; + + var serviceWorkerUrl = "flutter_service_worker.js?v=" + serviceWorkerVersion; + let loader = navigator.serviceWorker.register(serviceWorkerUrl) + .then((reg) => { + if (!reg.active && (reg.installing || reg.waiting)) { + // No active web worker and we have installed or are installing + // one for the first time. Simply wait for it to activate. + return this._waitForServiceWorkerActivation(reg.installing || reg.waiting, entrypointUrl); + } else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) { + // When the app updates the serviceWorkerVersion changes, so we + // need to ask the service worker to update. + console.log("New service worker available."); + reg.update(); + return this._waitForServiceWorkerActivation(reg.installing, entrypointUrl); + } else { + // Existing service worker is still good. + console.log("Loading app from service worker."); + return this._loadEntrypoint(entrypointUrl); + } + }); + + // Timeout race promise + let timeout; + if (timeoutMillis > 0) { + timeout = new Promise((resolve, _) => { + setTimeout(() => { + if (!this._scriptLoaded) { + console.warn("Failed to load app from service worker. Falling back to plain <script> tag."); + resolve(this._loadEntrypoint(entrypointUrl)); + } + }, timeoutMillis); + }); + } + + return Promise.race([loader, timeout]); + } + } + + _flutter.loader = new FlutterLoader(); +}()); +'''; +}
diff --git a/packages/flutter_tools/templates/app_shared/web/index.html.tmpl b/packages/flutter_tools/templates/app_shared/web/index.html.tmpl index 725f53d..2ac781f 100644 --- a/packages/flutter_tools/templates/app_shared/web/index.html.tmpl +++ b/packages/flutter_tools/templates/app_shared/web/index.html.tmpl
@@ -31,74 +31,28 @@ <title>{{projectName}}</title> <link rel="manifest" href="manifest.json"> + + <script> + // The value below is injected by flutter build, do not touch. + var serviceWorkerVersion = null; + </script> + <!-- This script adds the flutter initialization JS code --> + <script src="flutter.js" defer></script> </head> <body> - <!-- This script installs service_worker.js to provide PWA functionality to - application. For more information, see: - https://developers.google.com/web/fundamentals/primers/service-workers --> <script> - var serviceWorkerVersion = null; - var scriptLoaded = false; - function loadMainDartJs() { - if (scriptLoaded) { - return; - } - scriptLoaded = true; - var scriptTag = document.createElement('script'); - scriptTag.src = 'main.dart.js'; - scriptTag.type = 'application/javascript'; - document.body.append(scriptTag); - } - - if ('serviceWorker' in navigator) { - // Service workers are supported. Use them. - window.addEventListener('load', function () { - // Wait for registration to finish before dropping the <script> tag. - // Otherwise, the browser will load the script multiple times, - // potentially different versions. - var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion; - navigator.serviceWorker.register(serviceWorkerUrl) - .then((reg) => { - function waitForActivation(serviceWorker) { - serviceWorker.addEventListener('statechange', () => { - if (serviceWorker.state == 'activated') { - console.log('Installed new service worker.'); - loadMainDartJs(); - } - }); - } - if (!reg.active && (reg.installing || reg.waiting)) { - // No active web worker and we have installed or are installing - // one for the first time. Simply wait for it to activate. - waitForActivation(reg.installing || reg.waiting); - } else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) { - // When the app updates the serviceWorkerVersion changes, so we - // need to ask the service worker to update. - console.log('New service worker available.'); - reg.update(); - waitForActivation(reg.installing); - } else { - // Existing service worker is still good. - console.log('Loading app from service worker.'); - loadMainDartJs(); - } - }); - - // If service worker doesn't succeed in a reasonable amount of time, - // fallback to plain <script> tag. - setTimeout(() => { - if (!scriptLoaded) { - console.warn( - 'Failed to load app from service worker. Falling back to plain <script> tag.', - ); - loadMainDartJs(); - } - }, 4000); + window.addEventListener('load', function(ev) { + // Download main.dart.js + _flutter.loader.loadEntrypoint({ + serviceWorker: { + serviceWorkerVersion: serviceWorkerVersion, + } + }).then(function(engineInitializer) { + return engineInitializer.initializeEngine(); + }).then(function(appRunner) { + return appRunner.runApp(); }); - } else { - // Service workers not supported. Just drop the <script> tag. - loadMainDartJs(); - } + }); </script> </body> </html>
diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart index 0be396a..422189c 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart
@@ -13,6 +13,7 @@ import 'package:flutter_tools/src/build_system/depfile.dart'; import 'package:flutter_tools/src/build_system/targets/web.dart'; import 'package:flutter_tools/src/globals.dart' as globals; +import 'package:flutter_tools/src/web/flutter_js.dart' as flutter_js; import '../../../src/common.dart'; import '../../../src/fake_process_manager.dart'; @@ -82,11 +83,12 @@ expect(generated, contains("import 'package:foo/generated_plugin_registrant.dart';")); expect(generated, contains('registerPlugins(webPluginRegistrar);')); - // Main - expect(generated, contains('entrypoint.main();')); - // Import. expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); + + // Main + expect(generated, contains('ui.webOnlyWarmupEngine(')); + expect(generated, contains('entrypoint.main as _')); })); test('version.json is created after release build', () => testbed.run(() async { @@ -211,11 +213,12 @@ expect(generated, contains("import 'package:foo/generated_plugin_registrant.dart';")); expect(generated, contains('registerPlugins(webPluginRegistrar);')); - // Main - expect(generated, contains('entrypoint.main();')); - // Import. expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); + + // Main + expect(generated, contains('ui.webOnlyWarmupEngine(')); + expect(generated, contains('entrypoint.main as _')); }, overrides: <Type, Generator>{ Platform: () => windows, })); @@ -233,8 +236,14 @@ // Plugins expect(generated, isNot(contains("import 'package:foo/generated_plugin_registrant.dart';"))); expect(generated, isNot(contains('registerPlugins(webPluginRegistrar);'))); + + // Import. + expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); + // Main - expect(generated, contains('entrypoint.main();')); + expect(generated, contains('ui.webOnlyWarmupEngine(')); + expect(generated, contains('entrypoint.main as _')); + })); test('WebEntrypointTarget generates an entrypoint with a language version', () => testbed.run(() async { @@ -279,8 +288,12 @@ expect(generated, isNot(contains("import 'package:foo/generated_plugin_registrant.dart';"))); expect(generated, isNot(contains('registerPlugins(webPluginRegistrar);'))); + // Import. + expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); + // Main - expect(generated, contains('entrypoint.main();')); + expect(generated, contains('ui.webOnlyWarmupEngine(')); + expect(generated, contains('entrypoint.main as _')); })); test('Dart2JSTarget calls dart2js with expected args with csp', () => testbed.run(() async { @@ -683,4 +696,16 @@ expect(environment.outputDir.childFile('flutter_service_worker.js').readAsStringSync(), contains('"main.dart.js"')); })); + + test('flutter.js is not dynamically generated', () => testbed.run(() async { + globals.fs.file('bin/cache/flutter_web_sdk/canvaskit/foo') + ..createSync(recursive: true) + ..writeAsStringSync('OL'); + + await WebBuiltInAssets(globals.fs, globals.cache).build(environment); + + // No caching of source maps. + expect(environment.outputDir.childFile('flutter.js').readAsStringSync(), + equals(flutter_js.generateFlutterJsFile())); + })); }
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 3065118..70738a8 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
@@ -569,7 +569,7 @@ final String entrypointContents = fileSystem.file(webDevFS.mainUri).readAsStringSync(); expect(entrypointContents, contains('// Flutter web bootstrap script')); expect(entrypointContents, contains("import 'dart:ui' as ui;")); - expect(entrypointContents, contains('await ui.webOnlyInitializePlatform();')); + expect(entrypointContents, contains('await ui.webOnlyWarmupEngine(')); expect(logger.statusText, contains('Restarted application in')); expect(result.code, 0);
diff --git a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart index b9b4e19..9092588 100644 --- a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart +++ b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart
@@ -685,13 +685,14 @@ expect(webDevFS.webAssetServer.getFile('stack_trace_mapper.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('main.dart'), isNotNull); expect(webDevFS.webAssetServer.getFile('manifest.json'), isNotNull); + expect(webDevFS.webAssetServer.getFile('flutter.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('flutter_service_worker.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('version.json'),isNotNull); expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'HELLO'); expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js.map'), 'THERE'); // Update to the SDK. - globals.fs.file(webPrecompiledSdk).writeAsStringSync('BELLOW'); + globals.fs.file(webPrecompiledSdk).writeAsStringSync('BELLOW'); // New SDK should be visible.. expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'BELLOW'); @@ -795,6 +796,7 @@ expect(webDevFS.webAssetServer.getFile('stack_trace_mapper.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('main.dart'), isNotNull); expect(webDevFS.webAssetServer.getFile('manifest.json'), isNotNull); + expect(webDevFS.webAssetServer.getFile('flutter.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('flutter_service_worker.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('version.json'), isNotNull); expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'HELLO');