diff --git a/dev/a11y_assessments/web/index.html b/dev/a11y_assessments/web/index.html index a074dee382..43fc27544c 100644 --- a/dev/a11y_assessments/web/index.html +++ b/dev/a11y_assessments/web/index.html @@ -41,23 +41,8 @@ found in the LICENSE file. --> const serviceWorkerVersion = null; - - + diff --git a/dev/benchmarks/macrobenchmarks/web/flutter_bootstrap.js b/dev/benchmarks/macrobenchmarks/web/flutter_bootstrap.js new file mode 100644 index 0000000000..2813d7d46a --- /dev/null +++ b/dev/benchmarks/macrobenchmarks/web/flutter_bootstrap.js @@ -0,0 +1,8 @@ +{{flutter_js}} +{{flutter_build_config}} +_flutter.loader.load({ + config: { + // Use the local CanvasKit bundle instead of the CDN to reduce test flakiness. + canvasKitBaseUrl: "/canvaskit/", + }, +}); \ No newline at end of file diff --git a/dev/benchmarks/macrobenchmarks/web/index.html b/dev/benchmarks/macrobenchmarks/web/index.html index 30a39191b7..3d12df298c 100644 --- a/dev/benchmarks/macrobenchmarks/web/index.html +++ b/dev/benchmarks/macrobenchmarks/web/index.html @@ -6,16 +6,8 @@ found in the LICENSE file. --> Web Benchmarks - - + diff --git a/dev/bots/service_worker_test.dart b/dev/bots/service_worker_test.dart index 219d36be72..ef85aa3f39 100644 --- a/dev/bots/service_worker_test.dart +++ b/dev/bots/service_worker_test.dart @@ -163,7 +163,11 @@ Future _waitForAppToLoad( print('Waiting for app to load $waitForCounts'); await Future.any(>[ () async { + int tries = 1; while (!waitForCounts.entries.every((MapEntry entry) => (requestedPathCounts[entry.key] ?? 0) >= entry.value)) { + if (tries++ % 20 == 0) { + print('Still waiting. Requested so far: $requestedPathCounts'); + } await Future.delayed(const Duration(milliseconds: 100)); } }(), @@ -304,15 +308,16 @@ Future runWebServiceWorkerTest({ 'flutter.js': 1, 'main.dart.js': 1, 'flutter_service_worker.js': 1, + 'flutter_bootstrap.js': 1, 'assets/FontManifest.json': 1, 'assets/AssetManifest.bin.json': 1, 'assets/fonts/MaterialIcons-Regular.otf': 1, 'CLOSE': 1, - // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + // In headless mode Chrome does not load 'manifest.json' and 'favicon.png'. if (!headless) ...{ 'manifest.json': 1, - 'favicon.ico': 1, + 'favicon.png': 1, }, }); expect(reportedVersion, '1'); @@ -346,12 +351,13 @@ Future runWebServiceWorkerTest({ if (shouldExpectFlutterJs) 'flutter.js': 1, 'flutter_service_worker.js': 2, + 'flutter_bootstrap.js': 1, 'main.dart.js': 1, 'assets/AssetManifest.bin.json': 1, 'assets/FontManifest.json': 1, 'CLOSE': 1, if (!headless) - 'favicon.ico': 1, + 'favicon.png': 1, }); expect(reportedVersion, '2'); @@ -377,14 +383,15 @@ Future runWebServiceWorkerTest({ 'main.dart.js': 1, 'assets/FontManifest.json': 1, 'flutter_service_worker.js': 1, + 'flutter_bootstrap.js': 1, 'assets/AssetManifest.bin.json': 1, 'assets/fonts/MaterialIcons-Regular.otf': 1, 'CLOSE': 1, - // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + // In headless mode Chrome does not load 'manifest.json' and 'favicon.png'. if (!headless) ...{ 'manifest.json': 1, - 'favicon.ico': 1, + 'favicon.png': 1, }, }); @@ -429,6 +436,7 @@ Future runWebServiceWorkerTest({ if (shouldExpectFlutterJs) 'flutter.js': 1, 'flutter_service_worker.js': 2, + 'flutter_bootstrap.js': 1, 'main.dart.js': 1, 'assets/AssetManifest.bin.json': 1, 'assets/FontManifest.json': 1, @@ -436,7 +444,7 @@ Future runWebServiceWorkerTest({ if (!headless) ...{ 'manifest.json': 1, - 'favicon.ico': 1, + 'favicon.png': 1, }, }); @@ -508,8 +516,8 @@ Future runWebServiceWorkerTestWithCachingResources({ workingDirectory: _testAppWebDirectory, ); - final bool shouldExpectFlutterJs = testType != ServiceWorkerTestType.withoutFlutterJs; - + final bool usesFlutterBootstrapJs = testType == ServiceWorkerTestType.generatedEntrypoint; + final bool shouldExpectFlutterJs = !usesFlutterBootstrapJs && testType != ServiceWorkerTestType.withoutFlutterJs; print('BEGIN runWebServiceWorkerTestWithCachingResources(headless: $headless, testType: $testType)'); try { @@ -534,14 +542,15 @@ Future runWebServiceWorkerTestWithCachingResources({ 'flutter.js': 1, 'main.dart.js': 1, 'flutter_service_worker.js': 1, + 'flutter_bootstrap.js': usesFlutterBootstrapJs ? 2 : 1, 'assets/FontManifest.json': 1, 'assets/AssetManifest.bin.json': 1, 'assets/fonts/MaterialIcons-Regular.otf': 1, - // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + // In headless mode Chrome does not load 'manifest.json' and 'favicon.png'. if (!headless) ...{ 'manifest.json': 1, - 'favicon.ico': 1, + 'favicon.png': 1, }, }); @@ -593,13 +602,14 @@ Future runWebServiceWorkerTestWithCachingResources({ 'flutter.js': 1, 'main.dart.js': 1, 'flutter_service_worker.js': 2, + 'flutter_bootstrap.js': usesFlutterBootstrapJs ? 2 : 1, 'assets/FontManifest.json': 1, 'assets/AssetManifest.bin.json': 1, 'assets/fonts/MaterialIcons-Regular.otf': 1, - // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + // In headless mode Chrome does not load 'manifest.json' and 'favicon.png'. if (!headless) ...{ - 'favicon.ico': 1, + 'favicon.png': 1, }, }); } finally { @@ -682,11 +692,11 @@ Future runWebServiceWorkerTestWithBlockedServiceWorkers({ 'assets/FontManifest.json': 1, 'assets/fonts/MaterialIcons-Regular.otf': 1, 'CLOSE': 1, - // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + // In headless mode Chrome does not load 'manifest.json' and 'favicon.png'. if (!headless) ...{ 'manifest.json': 1, - 'favicon.ico': 1, + 'favicon.png': 1, }, }); } finally { @@ -770,14 +780,15 @@ Future runWebServiceWorkerTestWithCustomServiceWorkerVersion({ 'main.dart.js': 1, 'CLOSE': 1, 'flutter_service_worker.js': 1, + 'flutter_bootstrap.js': 1, 'assets/FontManifest.json': 1, 'assets/AssetManifest.bin.json': 1, 'assets/fonts/MaterialIcons-Regular.otf': 1, - // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + // In headless mode Chrome does not load 'manifest.json' and 'favicon.png'. if (!headless) ...{ 'manifest.json': 1, - 'favicon.ico': 1, + 'favicon.png': 1, }, }); @@ -794,11 +805,11 @@ Future runWebServiceWorkerTestWithCustomServiceWorkerVersion({ 'assets/FontManifest.json': 1, 'assets/fonts/MaterialIcons-Regular.otf': 1, 'CLOSE': 1, - // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + // In headless mode Chrome does not load 'manifest.json' and 'favicon.png'. if (!headless) ...{ 'manifest.json': 1, - 'favicon.ico': 1, + 'favicon.png': 1, }, }); @@ -816,11 +827,11 @@ Future runWebServiceWorkerTestWithCustomServiceWorkerVersion({ 'assets/FontManifest.json': 1, 'assets/fonts/MaterialIcons-Regular.otf': 1, 'CLOSE': 1, - // In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'. + // In headless mode Chrome does not load 'manifest.json' and 'favicon.png'. if (!headless) ...{ 'manifest.json': 1, - 'favicon.ico': 1, + 'favicon.png': 1, }, }); } finally { diff --git a/dev/integration_tests/web/lib/service_worker_test_cached_resources.dart b/dev/integration_tests/web/lib/service_worker_test_cached_resources.dart index f4664478df..2188af8c9d 100644 --- a/dev/integration_tests/web/lib/service_worker_test_cached_resources.dart +++ b/dev/integration_tests/web/lib/service_worker_test_cached_resources.dart @@ -5,14 +5,16 @@ import 'package:flutter/material.dart'; Future main() async { - runApp(const Scaffold( - body: Center( - child: Column( - children: [ - Icon(Icons.ac_unit), - Text('Hello, World', textDirection: TextDirection.ltr), - ], + runApp(const Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + body: Center( + child: Column( + children: [ + Icon(Icons.ac_unit), + Text('Hello, World', textDirection: TextDirection.ltr), + ], + ), ), - ), - )); + ))); } diff --git a/dev/integration_tests/web/web/index.html b/dev/integration_tests/web/web/index.html index 05d4deecc1..5dfce5041d 100644 --- a/dev/integration_tests/web/web/index.html +++ b/dev/integration_tests/web/web/index.html @@ -12,6 +12,9 @@ found in the LICENSE file. --> + + + diff --git a/dev/integration_tests/web/web/index_with_blocked_service_workers.html b/dev/integration_tests/web/web/index_with_blocked_service_workers.html index c590fdbd0a..9800cc3bba 100644 --- a/dev/integration_tests/web/web/index_with_blocked_service_workers.html +++ b/dev/integration_tests/web/web/index_with_blocked_service_workers.html @@ -12,6 +12,9 @@ found in the LICENSE file. --> + + + - + 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 d3423e7380..fad26c594d 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart @@ -17,11 +17,12 @@ import '../../dart/language_version.dart'; import '../../dart/package_map.dart'; import '../../flutter_plugins.dart'; import '../../globals.dart' as globals; -import '../../html_utils.dart'; import '../../project.dart'; +import '../../web/bootstrap.dart'; import '../../web/compile.dart'; import '../../web/file_generators/flutter_service_worker_js.dart'; import '../../web/file_generators/main_dart.dart' as main_dart; +import '../../web_template.dart'; import '../build_system.dart'; import '../depfile.dart'; import '../exceptions.dart'; @@ -328,18 +329,37 @@ class Dart2WasmTarget extends Dart2WebTarget { /// Unpacks the dart2js or dart2wasm compilation and resources to a given /// output directory. class WebReleaseBundle extends Target { - WebReleaseBundle(List configs) : this._withTargets( - configs.map((WebCompilerConfig config) => + WebReleaseBundle(List configs) : this._( + compileTargets: configs.map((WebCompilerConfig config) => switch (config) { WasmCompilerConfig() => Dart2WasmTarget(config), JsCompilerConfig() => Dart2JSTarget(config), } - ).toList() + ).toList(), ); - const WebReleaseBundle._withTargets(this.compileTargets); + WebReleaseBundle._({ + required this.compileTargets, + }) : templatedFilesTarget = WebTemplatedFiles(generateBuildConfigString(compileTargets)); + + static String generateBuildConfigString(List compileTargets) { + final List> buildDescriptions = compileTargets.map( + (Dart2WebTarget target) => target.buildConfig + ).toList(); + final Map buildConfig = { + 'engineRevision': globals.flutterVersion.engineRevision, + 'builds': buildDescriptions, + }; + return ''' +if (!window._flutter) { + window._flutter = {}; +} +_flutter.buildConfig = ${jsonEncode(buildConfig)}; +'''; + } final List compileTargets; + final WebTemplatedFiles templatedFilesTarget; List get buildFiles => compileTargets.fold( const Iterable.empty(), @@ -350,7 +370,10 @@ class WebReleaseBundle extends Target { String get name => 'web_release_bundle'; @override - List get dependencies => compileTargets; + List get dependencies => [ + ...compileTargets, + templatedFilesTarget, + ]; @override List get inputs => [ @@ -371,11 +394,12 @@ class WebReleaseBundle extends Target { @override Future build(Environment environment) async { + final FileSystem fileSystem = environment.fileSystem; for (final File outputFile in environment.buildDir.listSync(recursive: true).whereType()) { - final String basename = environment.fileSystem.path.basename(outputFile.path); + final String basename = fileSystem.path.basename(outputFile.path); if (buildFiles.contains(basename)) { outputFile.copySync( - environment.outputDir.childFile(environment.fileSystem.path.basename(outputFile.path)).path + environment.outputDir.childFile(fileSystem.path.basename(outputFile.path)).path ); } } @@ -404,34 +428,18 @@ class WebReleaseBundle extends Target { // Copy other resource files out of web/ directory. final List outputResourcesFiles = []; for (final File inputFile in inputResourceFiles) { - final File outputFile = environment.fileSystem.file(environment.fileSystem.path.join( + final String relativePath = fileSystem.path.relative(inputFile.path, from: webResources.path); + if (relativePath == 'index.html' || relativePath == 'flutter_bootstrap.js') { + // Skip these, these are handled by the templated file target. + continue; + } + final File outputFile = fileSystem.file(fileSystem.path.join( environment.outputDir.path, - environment.fileSystem.path.relative(inputFile.path, from: webResources.path))); + relativePath)); if (!outputFile.parent.existsSync()) { outputFile.parent.createSync(recursive: true); } outputResourcesFiles.add(outputFile); - // insert a random hash into the requests for service_worker.js. This is not a content hash, - // because it would need to be the hash for the entire bundle and not just the resource - // in question. - if (environment.fileSystem.path.basename(inputFile.path) == 'index.html') { - final List> buildDescriptions = compileTargets.map( - (Dart2WebTarget target) => target.buildConfig - ).toList(); - final Map buildConfig = { - 'engineRevision': globals.flutterVersion.engineRevision, - 'builds': buildDescriptions, - }; - final String buildConfigString = '_flutter.buildConfig = ${jsonEncode(buildConfig)};'; - final IndexHtml indexHtml = IndexHtml(inputFile.readAsStringSync()); - indexHtml.applySubstitutions( - baseHref: environment.defines[kBaseHref] ?? '/', - serviceWorkerVersion: Random().nextInt(4294967296).toString(), - buildConfig: buildConfigString, - ); - outputFile.writeAsStringSync(indexHtml.content); - continue; - } inputFile.copySync(outputFile.path); } final Depfile resourceFile = Depfile(inputResourceFiles, outputResourcesFiles); @@ -461,6 +469,110 @@ class WebReleaseBundle extends Target { } } +class WebTemplatedFiles extends Target { + WebTemplatedFiles(this.buildConfigString); + + final String buildConfigString; + + @override + String get buildKey => buildConfigString; + + void _emitWebTemplateWarning( + Environment environment, + String filePath, + WebTemplateWarning warning + ) { + environment.logger.printWarning( + 'Warning: In $filePath:${warning.lineNumber}: ${warning.warningText}' + ); + } + + @override + Future build(Environment environment) async { + final Directory webResources = environment.projectDir + .childDirectory('web'); + final File inputFlutterBootstrapJs = webResources.childFile('flutter_bootstrap.js'); + final String inputBootstrapContent; + if (await inputFlutterBootstrapJs.exists()) { + inputBootstrapContent = await inputFlutterBootstrapJs.readAsString(); + } else { + inputBootstrapContent = generateDefaultFlutterBootstrapScript(); + } + final WebTemplate bootstrapTemplate = WebTemplate(inputBootstrapContent); + for (final WebTemplateWarning warning in bootstrapTemplate.getWarnings()) { + _emitWebTemplateWarning(environment, 'flutter_bootstrap.js', warning); + } + + final FileSystem fileSystem = environment.fileSystem; + final File flutterJsFile = fileSystem.file(fileSystem.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + )); + + // Insert a random hash into the requests for service_worker.js. This is not a content hash, + // because it would need to be the hash for the entire bundle and not just the resource + // in question. + final String serviceWorkerVersion = Random().nextInt(4294967296).toString(); + bootstrapTemplate.applySubstitutions( + baseHref: '', + serviceWorkerVersion: serviceWorkerVersion, + flutterJsFile: flutterJsFile, + buildConfig: buildConfigString, + ); + + final File outputFlutterBootstrapJs = fileSystem.file(fileSystem.path.join( + environment.outputDir.path, + 'flutter_bootstrap.js' + )); + await outputFlutterBootstrapJs.writeAsString(bootstrapTemplate.content); + + await for (final FileSystemEntity file in webResources.list(recursive: true)) { + if (file is File && file.basename == 'index.html') { + final WebTemplate indexHtmlTemplate = WebTemplate(file.readAsStringSync()); + final String relativePath = fileSystem.path.relative(file.path, from: webResources.path); + + for (final WebTemplateWarning warning in indexHtmlTemplate.getWarnings()) { + _emitWebTemplateWarning(environment, relativePath, warning); + } + + indexHtmlTemplate.applySubstitutions( + baseHref: environment.defines[kBaseHref] ?? '/', + serviceWorkerVersion: serviceWorkerVersion, + flutterJsFile: flutterJsFile, + buildConfig: buildConfigString, + flutterBootstrapJs: bootstrapTemplate.content, + ); + final File outputIndexHtml = fileSystem.file(fileSystem.path.join( + environment.outputDir.path, + relativePath, + )); + await outputIndexHtml.create(recursive: true); + await outputIndexHtml.writeAsString(indexHtmlTemplate.content); + } + } + } + + + @override + List get dependencies => []; + + @override + List get inputs => const [ + Source.pattern('{PROJECT_DIR}/web/*/index.html'), + Source.pattern('{PROJECT_DIR}/web/flutter_bootstrap.js'), + Source.hostArtifact(HostArtifact.flutterWebSdk), + ]; + + @override + String get name => 'web_templated_files'; + + @override + List get outputs => const [ + Source.pattern('{OUTPUT_DIR}/*/index.html'), + Source.pattern('{OUTPUT_DIR}/flutter_bootstrap.js'), + ]; +} + /// Static assets provided by the Flutter SDK that do not change, such as /// CanvasKit. /// @@ -596,6 +708,7 @@ class WebServiceWorker extends Target { 'main.dart.mjs', ], 'index.html', + 'flutter_bootstrap.js', if (urlToHash.containsKey('assets/AssetManifest.bin.json')) 'assets/AssetManifest.bin.json', if (urlToHash.containsKey('assets/FontManifest.json')) diff --git a/packages/flutter_tools/lib/src/commands/build_web.dart b/packages/flutter_tools/lib/src/commands/build_web.dart index eb93f3ee31..7a4c6c06dd 100644 --- a/packages/flutter_tools/lib/src/commands/build_web.dart +++ b/packages/flutter_tools/lib/src/commands/build_web.dart @@ -8,13 +8,13 @@ import '../base/utils.dart'; import '../build_info.dart'; import '../features.dart'; import '../globals.dart' as globals; -import '../html_utils.dart'; import '../project.dart'; import '../runner/flutter_command.dart' show DevelopmentArtifact, FlutterCommandResult, FlutterOptions; import '../web/compile.dart'; import '../web/file_generators/flutter_service_worker_js.dart'; import '../web/web_constants.dart'; +import '../web_template.dart'; import 'build.dart'; class BuildWebCommand extends BuildSubCommand { diff --git a/packages/flutter_tools/lib/src/isolated/devfs_web.dart b/packages/flutter_tools/lib/src/isolated/devfs_web.dart index 9fbd859f31..a4a2c9f8d1 100644 --- a/packages/flutter_tools/lib/src/isolated/devfs_web.dart +++ b/packages/flutter_tools/lib/src/isolated/devfs_web.dart @@ -34,13 +34,13 @@ import '../dart/package_map.dart'; import '../devfs.dart'; import '../device.dart'; import '../globals.dart' as globals; -import '../html_utils.dart'; import '../project.dart'; import '../vmservice.dart'; import '../web/bootstrap.dart'; import '../web/chrome.dart'; import '../web/compile.dart'; import '../web/memory_fs.dart'; +import '../web_template.dart'; typedef DwdsLauncher = Future Function({ required AssetReader assetReader, @@ -120,7 +120,7 @@ class WebAssetServer implements AssetReader { this._nullSafetyMode, this._ddcModuleSystem, { required this.webRenderer, - }) : basePath = _getIndexHtml().getBaseHref(); + }) : basePath = _getWebTemplate('index.html', _kDefaultIndex).getBaseHref(); // Fallback to "application/octet-stream" on null which // makes no claims as to the structure of the data. @@ -386,7 +386,11 @@ class WebAssetServer implements AssetReader { // If the response is `/`, then we are requesting the index file. if (requestPath == '/' || requestPath.isEmpty) { - return _serveIndex(); + return _serveIndexHtml(); + } + + if (requestPath == 'flutter_bootstrap.js') { + return _serveFlutterBootstrapJs(); } final Map headers = {}; @@ -478,7 +482,7 @@ class WebAssetServer implements AssetReader { requestPath.startsWith('canvaskit/')) { return shelf.Response.notFound(''); } - return _serveIndex(); + return _serveIndexHtml(); } // For real files, use a serialized file stat plus path as a revision. @@ -524,8 +528,7 @@ class WebAssetServer implements AssetReader { /// Determines what rendering backed to use. final WebRendererMode webRenderer; - shelf.Response _serveIndex() { - final IndexHtml indexHtml = _getIndexHtml(); + String get _buildConfigString { final Map buildConfig = { 'engineRevision': globals.flutterVersion.engineRevision, 'builds': [ @@ -536,19 +539,52 @@ class WebAssetServer implements AssetReader { }, ], }; - final String buildConfigString = '_flutter.buildConfig = ${jsonEncode(buildConfig)};'; + return ''' +if (!window._flutter) { + window._flutter = {}; +} +_flutter.buildConfig = ${jsonEncode(buildConfig)}; +'''; + } + File get _flutterJsFile => globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + )); + + String get _flutterBootstrapJsContent { + final WebTemplate bootstrapTemplate = _getWebTemplate( + 'flutter_bootstrap.js', + generateDefaultFlutterBootstrapScript() + ); + bootstrapTemplate.applySubstitutions( + baseHref: '/', + serviceWorkerVersion: null, + buildConfig: _buildConfigString, + flutterJsFile: _flutterJsFile, + ); + return bootstrapTemplate.content; + } + + shelf.Response _serveFlutterBootstrapJs() { + return shelf.Response.ok(_flutterBootstrapJsContent, headers: { + HttpHeaders.contentTypeHeader: 'text/javascript', + }); + } + + shelf.Response _serveIndexHtml() { + final WebTemplate indexHtml = _getWebTemplate('index.html', _kDefaultIndex); indexHtml.applySubstitutions( // Currently, we don't support --base-href for the "run" command. baseHref: '/', serviceWorkerVersion: null, - buildConfig: buildConfigString, + buildConfig: _buildConfigString, + flutterJsFile: _flutterJsFile, + flutterBootstrapJs: _flutterBootstrapJsContent, ); - - final Map headers = { + return shelf.Response.ok(indexHtml.content, headers: { HttpHeaders.contentTypeHeader: 'text/html', - }; - return shelf.Response.ok(indexHtml.content, headers: headers); + }); } // Attempt to resolve `path` to a dart file. @@ -860,6 +896,21 @@ class WebDevFS implements DevFS { @override final Directory rootDirectory; + Future _validateTemplateFile(String filename) async { + final File file = + globals.fs.currentDirectory.childDirectory('web').childFile(filename); + if (!await file.exists()) { + return; + } + + final WebTemplate template = WebTemplate(await file.readAsString()); + for (final WebTemplateWarning warning in template.getWarnings()) { + globals.logger.printWarning( + 'Warning: In $filename:${warning.lineNumber}: ${warning.warningText}' + ); + } + } + @override Future update({ required Uri mainUri, @@ -950,6 +1001,8 @@ class WebDevFS implements DevFS { ); } } + await _validateTemplateFile('index.html'); + await _validateTemplateFile('flutter_bootstrap.js'); final DateTime candidateCompileTime = DateTime.now(); if (fullRestart) { generator.reset(); @@ -1173,10 +1226,10 @@ String? _stripBasePath(String path, String basePath) { return stripLeadingSlash(path); } -IndexHtml _getIndexHtml() { - final File indexHtml = - globals.fs.currentDirectory.childDirectory('web').childFile('index.html'); +WebTemplate _getWebTemplate(String filename, String fallbackContent) { + final File template = + globals.fs.currentDirectory.childDirectory('web').childFile(filename); final String htmlContent = - indexHtml.existsSync() ? indexHtml.readAsStringSync() : _kDefaultIndex; - return IndexHtml(htmlContent); + template.existsSync() ? template.readAsStringSync() : fallbackContent; + return WebTemplate(htmlContent); } diff --git a/packages/flutter_tools/lib/src/web/bootstrap.dart b/packages/flutter_tools/lib/src/web/bootstrap.dart index f1a55f4497..c31c6e4e1d 100644 --- a/packages/flutter_tools/lib/src/web/bootstrap.dart +++ b/packages/flutter_tools/lib/src/web/bootstrap.dart @@ -472,3 +472,16 @@ String generateTestBootstrapFileContents( })(); '''; } + +String generateDefaultFlutterBootstrapScript() { + return ''' +{{flutter_js}} +{{flutter_build_config}} + +_flutter.loader.load({ + serviceWorkerSettings: { + serviceWorkerVersion: {{flutter_service_worker_version}} + } +}); +'''; +} diff --git a/packages/flutter_tools/lib/src/web/file_generators/js/flutter.js b/packages/flutter_tools/lib/src/web/file_generators/js/flutter.js deleted file mode 100644 index ac0e9fc333..0000000000 --- a/packages/flutter_tools/lib/src/web/file_generators/js/flutter.js +++ /dev/null @@ -1,380 +0,0 @@ -// 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. - -if (!_flutter) { - var _flutter = {}; -} -_flutter.loader = null; - -(function () { - "use strict"; - - const baseUri = ensureTrailingSlash(getBaseURI()); - - function getBaseURI() { - const base = document.querySelector("base"); - return (base && base.getAttribute("href")) || ""; - } - - function ensureTrailingSlash(uri) { - if (uri == "") { - return uri; - } - return uri.endsWith("/") ? uri : `${uri}/`; - } - - /** - * Wraps `promise` in a timeout of the given `duration` in ms. - * - * Resolves/rejects with whatever the original `promises` does, or rejects - * if `promise` takes longer to complete than `duration`. In that case, - * `debugName` is used to compose a legible error message. - * - * If `duration` is < 0, the original `promise` is returned unchanged. - * @param {Promise} promise - * @param {number} duration - * @param {string} debugName - * @returns {Promise} a wrapped promise. - */ - async function timeout(promise, duration, debugName) { - if (duration < 0) { - return promise; - } - let timeoutId; - const _clock = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject( - new Error( - `${debugName} took more than ${duration}ms to resolve. Moving on.`, - { - cause: timeout, - } - ) - ); - }, duration); - }); - - return Promise.race([promise, _clock]).finally(() => { - clearTimeout(timeoutId); - }); - } - - /** - * Handles the creation of a TrustedTypes `policy` that validates URLs based - * on an (optional) incoming array of RegExes. - */ - class FlutterTrustedTypesPolicy { - /** - * Constructs the policy. - * @param {[RegExp]} validPatterns the patterns to test URLs - * @param {String} policyName the policy name (optional) - */ - constructor(validPatterns, policyName = "flutter-js") { - const patterns = validPatterns || [ - /\.js$/, - ]; - if (window.trustedTypes) { - this.policy = trustedTypes.createPolicy(policyName, { - createScriptURL: function(url) { - const parsed = new URL(url, window.location); - const file = parsed.pathname.split("/").pop(); - const matches = patterns.some((pattern) => pattern.test(file)); - if (matches) { - return parsed.toString(); - } - console.error( - "URL rejected by TrustedTypes policy", - policyName, ":", url, "(download prevented)"); - } - }); - } - } - } - - /** - * Handles loading/reloading Flutter's service worker, if configured. - * - * @see: https://developers.google.com/web/fundamentals/primers/service-workers - */ - class FlutterServiceWorkerLoader { - /** - * Injects a TrustedTypesPolicy (or undefined if the feature is not supported). - * @param {TrustedTypesPolicy | undefined} policy - */ - setTrustedTypesPolicy(policy) { - this._ttPolicy = policy; - } - - /** - * Returns a Promise that resolves when the latest Flutter service worker, - * configured by `settings` has been loaded and activated. - * - * Otherwise, the promise is rejected with an error message. - * @param {*} settings Service worker settings - * @returns {Promise} that resolves when the latest serviceWorker is ready. - */ - loadServiceWorker(settings) { - if (settings == null) { - // In the future, settings = null -> uninstall service worker? - console.debug("Null serviceWorker configuration. Skipping."); - return Promise.resolve(); - } - if (!("serviceWorker" in navigator)) { - let errorMessage = "Service Worker API unavailable."; - if (!window.isSecureContext) { - errorMessage += "\nThe current context is NOT secure." - errorMessage += "\nRead more: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts"; - } - return Promise.reject( - new Error(errorMessage) - ); - } - const { - serviceWorkerVersion, - serviceWorkerUrl = `${baseUri}flutter_service_worker.js?v=${serviceWorkerVersion}`, - timeoutMillis = 4000, - } = settings; - - // Apply the TrustedTypes policy, if present. - let url = serviceWorkerUrl; - if (this._ttPolicy != null) { - url = this._ttPolicy.createScriptURL(url); - } - - const serviceWorkerActivation = navigator.serviceWorker - .register(url) - .then((serviceWorkerRegistration) => this._getNewServiceWorker(serviceWorkerRegistration, serviceWorkerVersion)) - .then(this._waitForServiceWorkerActivation); - - // Timeout race promise - return timeout( - serviceWorkerActivation, - timeoutMillis, - "prepareServiceWorker" - ); - } - - /** - * Returns the latest service worker for the given `serviceWorkerRegistration`. - * - * This might return the current service worker, if there's no new service worker - * awaiting to be installed/updated. - * - * @param {ServiceWorkerRegistration} serviceWorkerRegistration - * @param {String} serviceWorkerVersion - * @returns {Promise} - */ - async _getNewServiceWorker(serviceWorkerRegistration, serviceWorkerVersion) { - if (!serviceWorkerRegistration.active && (serviceWorkerRegistration.installing || serviceWorkerRegistration.waiting)) { - // No active web worker and we have installed or are installing - // one for the first time. Simply wait for it to activate. - console.debug("Installing/Activating first service worker."); - return serviceWorkerRegistration.installing || serviceWorkerRegistration.waiting; - } else if (!serviceWorkerRegistration.active.scriptURL.endsWith(serviceWorkerVersion)) { - // When the app updates the serviceWorkerVersion changes, so we - // need to ask the service worker to update. - const newRegistration = await serviceWorkerRegistration.update(); - console.debug("Updating service worker."); - return newRegistration.installing || newRegistration.waiting || newRegistration.active; - } else { - console.debug("Loading from existing service worker."); - return serviceWorkerRegistration.active; - } - } - - /** - * Returns a Promise that resolves when the `serviceWorker` changes its - * state to "activated". - * - * @param {ServiceWorker} serviceWorker - * @returns {Promise} - */ - async _waitForServiceWorkerActivation(serviceWorker) { - if (!serviceWorker || serviceWorker.state == "activated") { - if (!serviceWorker) { - throw new Error("Cannot activate a null service worker!"); - } else { - console.debug("Service worker already active."); - return; - } - } - return new Promise((resolve, _) => { - serviceWorker.addEventListener("statechange", () => { - if (serviceWorker.state == "activated") { - console.debug("Activated new service worker."); - resolve(); - } - }); - }); - } - } - - /** - * Handles injecting the main Flutter web entrypoint (main.dart.js), and notifying - * the user when Flutter is ready, through `didCreateEngineInitializer`. - * - * @see https://docs.flutter.dev/development/platform-integration/web/initialization - */ - class FlutterEntrypointLoader { - /** - * Creates a FlutterEntrypointLoader. - */ - constructor() { - // Watchdog to prevent injecting the main entrypoint multiple times. - this._scriptLoaded = false; - } - - /** - * Injects a TrustedTypesPolicy (or undefined if the feature is not supported). - * @param {TrustedTypesPolicy | undefined} policy - */ - setTrustedTypesPolicy(policy) { - this._ttPolicy = policy; - } - - /** - * Loads flutter main entrypoint, specified by `entrypointUrl`, and calls a - * user-specified `onEntrypointLoaded` callback with an EngineInitializer - * object when it's done. - * - * @param {*} options - * @returns {Promise | undefined} that will eventually resolve with an - * EngineInitializer, or will be rejected with the error caused by the loader. - * Returns undefined when an `onEntrypointLoaded` callback is supplied in `options`. - */ - async loadEntrypoint(options) { - const { entrypointUrl = `${baseUri}main.dart.js`, onEntrypointLoaded, nonce } = - options || {}; - - return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded, nonce); - } - - /** - * Resolves the promise created by loadEntrypoint, and calls the `onEntrypointLoaded` - * function supplied by the user (if needed). - * - * Called by Flutter through `_flutter.loader.didCreateEngineInitializer` method, - * which is bound to the correct instance of the FlutterEntrypointLoader by - * the FlutterLoader object. - * - * @param {Function} engineInitializer @see https://github.com/flutter/engine/blob/main/lib/web_ui/lib/src/engine/js_interop/js_loader.dart#L42 - */ - didCreateEngineInitializer(engineInitializer) { - if (typeof this._didCreateEngineInitializerResolve === "function") { - this._didCreateEngineInitializerResolve(engineInitializer); - // Remove the resolver after the first time, so Flutter Web can hot restart. - this._didCreateEngineInitializerResolve = null; - // Make the engine revert to "auto" initialization on hot restart. - delete _flutter.loader.didCreateEngineInitializer; - } - if (typeof this._onEntrypointLoaded === "function") { - this._onEntrypointLoaded(engineInitializer); - } - } - - /** - * Injects a script tag into the DOM, and configures this loader to be able to - * handle the "entrypoint loaded" notifications received from Flutter web. - * - * @param {string} entrypointUrl the URL of the script that will initialize - * Flutter. - * @param {Function} onEntrypointLoaded a callback that will be called when - * Flutter web notifies this object that the entrypoint is - * loaded. - * @returns {Promise | undefined} a Promise that resolves when the entrypoint - * is loaded, or undefined if `onEntrypointLoaded` - * is a function. - */ - _loadEntrypoint(entrypointUrl, onEntrypointLoaded, nonce) { - const useCallback = typeof onEntrypointLoaded === "function"; - - if (!this._scriptLoaded) { - this._scriptLoaded = true; - const scriptTag = this._createScriptTag(entrypointUrl, nonce); - if (useCallback) { - // Just inject the script tag, and return nothing; Flutter will call - // `didCreateEngineInitializer` when it's done. - console.debug("Injecting - - - + 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 46c4e1cc7b..d54228ef07 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 @@ -12,10 +12,10 @@ import 'package:flutter_tools/src/build_system/build_system.dart'; 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/html_utils.dart'; import 'package:flutter_tools/src/isolated/mustache_template.dart'; import 'package:flutter_tools/src/web/compile.dart'; import 'package:flutter_tools/src/web/file_generators/flutter_service_worker_js.dart'; +import 'package:flutter_tools/src/web_template.dart'; import '../../../src/common.dart'; import '../../../src/fake_process_manager.dart'; @@ -144,9 +144,7 @@ void main() { '''); environment.buildDir.childFile('main.dart.js').createSync(); - await WebReleaseBundle([ - const JsCompilerConfig() - ]).build(environment); + await WebTemplatedFiles('buildConfig').build(environment); expect(environment.outputDir.childFile('index.html').readAsStringSync(), contains('/basehreftest/')); })); @@ -159,9 +157,7 @@ void main() { '''); environment.buildDir.childFile('main.dart.js').createSync(); - await WebReleaseBundle([ - const JsCompilerConfig() - ]).build(environment); + await WebTemplatedFiles('build config').build(environment); expect(environment.outputDir.childFile('index.html').readAsStringSync(), contains('/basehreftest/')); })); @@ -169,18 +165,9 @@ void main() { test('WebReleaseBundle copies dart2js output and resource files to output directory', () => testbed.run(() async { environment.defines[kBuildMode] = 'release'; final Directory webResources = environment.projectDir.childDirectory('web'); - webResources.childFile('index.html') - ..createSync(recursive: true) - ..writeAsStringSync(''' - - - - -'''); webResources.childFile('foo.txt') - .writeAsStringSync('A'); + ..createSync(recursive: true) + ..writeAsStringSync('A'); environment.buildDir.childFile('main.dart.js').createSync(); environment.buildDir.childFile('main.dart.js.map').createSync(); @@ -206,11 +193,6 @@ void main() { expect(environment.outputDir.childFile('foo.txt') .readAsStringSync(), 'B'); - // Appends number to requests for service worker only - expect(environment.outputDir.childFile('index.html').readAsStringSync(), allOf( - contains(' - - -'''; - -const String htmlSample2 = ''' - - - - - - - - - -
- - - - - -'''; - -const String htmlSampleLegacyVar = ''' - - - - - - - - - -
- - - - - -'''; - -String htmlSample2Replaced({ - required String baseHref, - required String serviceWorkerVersion, -}) => - ''' - - - - - - - - - -
- - - - - -'''; - -const String htmlSample3 = ''' - - - - - - - - -
- - - -'''; - -void main() { - test('can parse baseHref', () { - expect(IndexHtml('').getBaseHref(), 'foo/111'); - expect(IndexHtml(htmlSample1).getBaseHref(), 'foo/222'); - expect(IndexHtml(htmlSample2).getBaseHref(), ''); // Placeholder base href. - }); - - test('handles missing baseHref', () { - expect(IndexHtml('').getBaseHref(), ''); - expect(IndexHtml('').getBaseHref(), ''); - expect(IndexHtml(htmlSample3).getBaseHref(), ''); - }); - - test('throws on invalid baseHref', () { - expect(() => IndexHtml('').getBaseHref(), throwsToolExit()); - expect(() => IndexHtml('').getBaseHref(), throwsToolExit()); - expect(() => IndexHtml('').getBaseHref(), throwsToolExit()); - expect( - () => IndexHtml('').getBaseHref(), - throwsToolExit(), - ); - expect( - () => IndexHtml('').getBaseHref(), - throwsToolExit(), - ); - }); - - test('applies substitutions', () { - final IndexHtml indexHtml = IndexHtml(htmlSample2); - indexHtml.applySubstitutions( - baseHref: '/foo/333/', - serviceWorkerVersion: 'v123xyz', - ); - expect( - indexHtml.content, - htmlSample2Replaced( - baseHref: '/foo/333/', - serviceWorkerVersion: 'v123xyz', - ), - ); - }); - - test('applies substitutions with legacy var version syntax', () { - final IndexHtml indexHtml = IndexHtml(htmlSampleLegacyVar); - indexHtml.applySubstitutions( - baseHref: '/foo/333/', - serviceWorkerVersion: 'v123xyz', - ); - expect( - indexHtml.content, - htmlSample2Replaced( - baseHref: '/foo/333/', - serviceWorkerVersion: 'v123xyz', - ), - ); - }); - - test('re-parses after substitutions', () { - final IndexHtml indexHtml = IndexHtml(htmlSample2); - expect(indexHtml.getBaseHref(), ''); // Placeholder base href. - - indexHtml.applySubstitutions( - baseHref: '/foo/333/', - serviceWorkerVersion: 'v123xyz', - ); - // The parsed base href should be updated after substitutions. - expect(indexHtml.getBaseHref(), 'foo/333'); - }); -} diff --git a/packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart b/packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart index 2d57d53e2b..bcb9e247a0 100644 --- a/packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart +++ b/packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart @@ -17,9 +17,9 @@ import 'package:flutter_tools/src/compile.dart'; import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/devfs.dart'; import 'package:flutter_tools/src/globals.dart' as globals; -import 'package:flutter_tools/src/html_utils.dart'; import 'package:flutter_tools/src/isolated/devfs_web.dart'; import 'package:flutter_tools/src/web/compile.dart'; +import 'package:flutter_tools/src/web_template.dart'; import 'package:logging/logging.dart' as logging; import 'package:package_config/package_config.dart'; import 'package:shelf/shelf.dart'; @@ -344,6 +344,11 @@ void main() { final Directory webDir = globals.fs.currentDirectory.childDirectory('web')..createSync(); webDir.childFile('index.html').writeAsStringSync(htmlContent); + globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ))..createSync(recursive: true)..writeAsStringSync('flutter.js content'); + final Response response = await webAssetServer.handleRequest( Request('GET', Uri.parse('http://foobar/base/path/'))); @@ -360,6 +365,10 @@ void main() { final Directory webDir = globals.fs.currentDirectory.childDirectory('web')..createSync(); webDir.childFile('index.html').writeAsStringSync(htmlContent); + globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ))..createSync(recursive: true)..writeAsStringSync('flutter.js content'); final Response response = await webAssetServer .handleRequest(Request('GET', Uri.parse('http://foobar/'))); @@ -530,6 +539,10 @@ void main() { final Directory webDir = globals.fs.currentDirectory.childDirectory('web')..createSync(); webDir.childFile('index.html').writeAsStringSync(htmlContent); + globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ))..createSync(recursive: true)..writeAsStringSync('flutter.js content'); final Response response = await webAssetServer.handleRequest( Request('GET', Uri.parse('http://foobar/bar/baz'))); @@ -586,6 +599,11 @@ void main() { test( 'serves default index.html', () => testbed.run(() async { + globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ))..createSync(recursive: true)..writeAsStringSync('flutter.js content'); + final Response response = await webAssetServer .handleRequest(Request('GET', Uri.parse('http://foobar/'))); 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 80141e90fd..7802b1d148 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 @@ -17,9 +17,9 @@ import 'package:flutter_tools/src/compile.dart'; import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/devfs.dart'; import 'package:flutter_tools/src/globals.dart' as globals; -import 'package:flutter_tools/src/html_utils.dart'; import 'package:flutter_tools/src/isolated/devfs_web.dart'; import 'package:flutter_tools/src/web/compile.dart'; +import 'package:flutter_tools/src/web_template.dart'; import 'package:logging/logging.dart' as logging; import 'package:package_config/package_config.dart'; import 'package:shelf/shelf.dart'; @@ -244,6 +244,10 @@ void main() { .childDirectory('web') ..createSync(); webDir.childFile('index.html').writeAsStringSync(htmlContent); + globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ))..createSync(recursive: true)..writeAsStringSync('flutter.js content'); final Response response = await webAssetServer .handleRequest(Request('GET', Uri.parse('http://foobar/base/path/'))); @@ -257,6 +261,11 @@ void main() { final Directory webDir = globals.fs.currentDirectory.childDirectory('web') ..createSync(); webDir.childFile('index.html').writeAsStringSync(htmlContent); + globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ))..createSync(recursive: true)..writeAsStringSync('flutter.js content'); + final Response response = await webAssetServer .handleRequest(Request('GET', Uri.parse('http://foobar/'))); @@ -404,6 +413,10 @@ void main() { .childDirectory('web') ..createSync(); webDir.childFile('index.html').writeAsStringSync(htmlContent); + globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ))..createSync(recursive: true)..writeAsStringSync('flutter.js content'); final Response response = await webAssetServer .handleRequest(Request('GET', Uri.parse('http://foobar/bar/baz'))); @@ -454,6 +467,11 @@ void main() { })); test('serves default index.html', () => testbed.run(() async { + globals.fs.file(globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ))..createSync(recursive: true)..writeAsStringSync('flutter.js content'); + final Response response = await webAssetServer .handleRequest(Request('GET', Uri.parse('http://foobar/'))); @@ -830,6 +848,10 @@ void main() { .getHostArtifact(HostArtifact.webPrecompiledAmdCanvaskitSoundSdk).path; final String webPrecompiledCanvaskitSdkSourcemaps = globals.artifacts! .getHostArtifact(HostArtifact.webPrecompiledAmdCanvaskitSoundSdkSourcemaps).path; + final String flutterJs = globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ); globals.fs.file(webPrecompiledSdk) ..createSync(recursive: true) ..writeAsStringSync('HELLO'); @@ -842,6 +864,9 @@ void main() { globals.fs.file(webPrecompiledCanvaskitSdkSourcemaps) ..createSync(recursive: true) ..writeAsStringSync('CHUM'); + globals.fs.file(flutterJs) + ..createSync(recursive: true) + ..writeAsStringSync('(flutter.js content)'); await webDevFS.update( mainUri: globals.fs.file(globals.fs.path.join('lib', 'main.dart')).uri, diff --git a/packages/flutter_tools/test/general.shard/web_template_test.dart b/packages/flutter_tools/test/general.shard/web_template_test.dart new file mode 100644 index 0000000000..d752a75271 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/web_template_test.dart @@ -0,0 +1,341 @@ +// 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 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/web_template.dart'; + +import '../src/common.dart'; + +const String htmlSample1 = ''' + + + + + + + + + +
+ + + +'''; + +const String htmlSample2 = ''' + + + + + + + + + +
+ + + + + +'''; + +const String htmlSampleInlineFlutterJsBootstrap = ''' + + + + + + + + + +
+ + + +'''; + +const String htmlSampleInlineFlutterJsBootstrapOutput = ''' + + + + + + + + + +
+ + + +'''; + +const String htmlSampleFullFlutterBootstrapReplacement = ''' + + + + + + + + + +
+ + + +'''; + +const String htmlSampleFullFlutterBootstrapReplacementOutput = ''' + + + + + + + + + +
+ + + +'''; + +const String htmlSampleLegacyVar = ''' + + + + + + + + + +
+ + + + + +'''; + +const String htmlSampleLegacyLoadEntrypoint = ''' + + + + + + + + + + +
+ + + +'''; + +String htmlSample2Replaced({ + required String baseHref, + required String serviceWorkerVersion, +}) => + ''' + + + + + + + + + +
+ + + + + +'''; + +const String htmlSample3 = ''' + + + + + + + + +
+ + + +'''; + +void main() { + final MemoryFileSystem fs = MemoryFileSystem(); + final File flutterJs = fs.file('flutter.js'); + flutterJs.writeAsStringSync('(flutter.js content)'); + + test('can parse baseHref', () { + expect(WebTemplate('').getBaseHref(), 'foo/111'); + expect(WebTemplate(htmlSample1).getBaseHref(), 'foo/222'); + expect(WebTemplate(htmlSample2).getBaseHref(), ''); // Placeholder base href. + }); + + test('handles missing baseHref', () { + expect(WebTemplate('').getBaseHref(), ''); + expect(WebTemplate('').getBaseHref(), ''); + expect(WebTemplate(htmlSample3).getBaseHref(), ''); + }); + + test('throws on invalid baseHref', () { + expect(() => WebTemplate('').getBaseHref(), throwsToolExit()); + expect(() => WebTemplate('').getBaseHref(), throwsToolExit()); + expect(() => WebTemplate('').getBaseHref(), throwsToolExit()); + expect( + () => WebTemplate('').getBaseHref(), + throwsToolExit(), + ); + expect( + () => WebTemplate('').getBaseHref(), + throwsToolExit(), + ); + }); + + test('applies substitutions', () { + final WebTemplate indexHtml = WebTemplate(htmlSample2); + indexHtml.applySubstitutions( + baseHref: '/foo/333/', + serviceWorkerVersion: 'v123xyz', + flutterJsFile: flutterJs, + ); + expect( + indexHtml.content, + htmlSample2Replaced( + baseHref: '/foo/333/', + serviceWorkerVersion: 'v123xyz', + ), + ); + }); + + test('applies substitutions with legacy var version syntax', () { + final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyVar); + indexHtml.applySubstitutions( + baseHref: '/foo/333/', + serviceWorkerVersion: 'v123xyz', + flutterJsFile: flutterJs, + ); + expect( + indexHtml.content, + htmlSample2Replaced( + baseHref: '/foo/333/', + serviceWorkerVersion: 'v123xyz', + ), + ); + }); + + test('applies substitutions to inline flutter.js bootstrap script', () { + final WebTemplate indexHtml = WebTemplate(htmlSampleInlineFlutterJsBootstrap); + expect(indexHtml.getWarnings(), isEmpty); + + indexHtml.applySubstitutions( + baseHref: '/', + serviceWorkerVersion: '(service worker version)', + flutterJsFile: flutterJs, + buildConfig: '(build config)', + ); + expect(indexHtml.content, htmlSampleInlineFlutterJsBootstrapOutput); + }); + + test('applies substitutions to full flutter_bootstrap.js replacement', () { + final WebTemplate indexHtml = WebTemplate(htmlSampleFullFlutterBootstrapReplacement); + expect(indexHtml.getWarnings(), isEmpty); + + indexHtml.applySubstitutions( + baseHref: '/', + serviceWorkerVersion: '(service worker version)', + flutterJsFile: flutterJs, + buildConfig: '(build config)', + flutterBootstrapJs: '(flutter bootstrap script)', + ); + expect(indexHtml.content, htmlSampleFullFlutterBootstrapReplacementOutput); + }); + + test('re-parses after substitutions', () { + final WebTemplate indexHtml = WebTemplate(htmlSample2); + expect(indexHtml.getBaseHref(), ''); // Placeholder base href. + + indexHtml.applySubstitutions( + baseHref: '/foo/333/', + serviceWorkerVersion: 'v123xyz', + flutterJsFile: flutterJs, + ); + // The parsed base href should be updated after substitutions. + expect(indexHtml.getBaseHref(), 'foo/333'); + }); + + test('warns on legacy service worker patterns', () { + final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyVar); + final List warnings = indexHtml.getWarnings(); + expect(warnings.length, 2); + + expect(warnings.where((WebTemplateWarning warning) => warning.lineNumber == 13), isNotEmpty); + expect(warnings.where((WebTemplateWarning warning) => warning.lineNumber == 16), isNotEmpty); + }); + + test('warns on legacy FlutterLoader.loadEntrypoint', () { + final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyLoadEntrypoint); + final List warnings = indexHtml.getWarnings(); + + expect(warnings.length, 1); + expect(warnings.single.lineNumber, 14); + }); +} diff --git a/packages/flutter_tools/test/web.shard/hot_reload_web_test.dart b/packages/flutter_tools/test/web.shard/hot_reload_web_test.dart index 6220347af5..6999255b02 100644 --- a/packages/flutter_tools/test/web.shard/hot_reload_web_test.dart +++ b/packages/flutter_tools/test/web.shard/hot_reload_web_test.dart @@ -21,6 +21,8 @@ void main() async { await _testProject(HotReloadProject(indexHtml: indexHtmlFlutterJsPromisesShort), name: 'flutter.js (promises, short)'); await _testProject(HotReloadProject(indexHtml: indexHtmlFlutterJsLoad), name: 'flutter.js (load)'); await _testProject(HotReloadProject(indexHtml: indexHtmlNoFlutterJs), name: 'No flutter.js'); + await _testProject(HotReloadProject(indexHtml: indexHtmlWithFlutterBootstrapScriptTag), name: 'Using flutter_bootstrap.js script tag'); + await _testProject(HotReloadProject(indexHtml: indexHtmlWithInlinedFlutterBootstrapScript), name: 'Using inlined flutter_bootstrap.js'); } Future _testProject(HotReloadProject project, {String name = 'Default'}) async { @@ -73,8 +75,7 @@ Future _testProject(HotReloadProject project, {String name = 'Default'}) a completer.complete(); } }); - await flutter.run(chrome: true, - additionalCommandArgs: ['--dart-define=FLUTTER_WEB_USE_SKIA=true', '--verbose']); + await flutter.run(chrome: true, additionalCommandArgs: ['--verbose', '--web-renderer=canvaskit']); project.uncommentHotReloadPrint(); try { await flutter.hotRestart(); diff --git a/packages/flutter_tools/test/web.shard/test_data/hot_reload_index_html_samples.dart b/packages/flutter_tools/test/web.shard/test_data/hot_reload_index_html_samples.dart index 873604bf97..fab7d5ba15 100644 --- a/packages/flutter_tools/test/web.shard/test_data/hot_reload_index_html_samples.dart +++ b/packages/flutter_tools/test/web.shard/test_data/hot_reload_index_html_samples.dart @@ -198,3 +198,53 @@ $initScript '''; + +/// index.html using flutter bootstrap script +const String indexHtmlWithFlutterBootstrapScriptTag = ''' + + + + + + + + Web Test + + + + + + + + + + +'''; + +/// index.html using flutter bootstrap script +const String indexHtmlWithInlinedFlutterBootstrapScript = ''' + + + + + + + + Web Test + + + + + + + + + + +''';