From 150f0fb6f0438a98a5966fef64572374b0d80e76 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 1 Aug 2022 17:29:37 -0700 Subject: [PATCH 1/4] [web] Add onEntrypointLoaded to FlutterLoader. Also: untangle service worker loader code to its own class, ServiceWorkerLoader so it can be modularized/overridden later. --- .../src/web/file_generators/flutter_js.dart | 263 +++++++++++------- 1 file changed, 170 insertions(+), 93 deletions(-) diff --git a/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart b/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart index 257cb42d3918a..24ff3c044bc54 100644 --- a/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart +++ b/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart @@ -8,17 +8,11 @@ /// flutter.js should be completely static, so **do not use any parameter or /// environment variable to generate this file**. String generateFlutterJsFile() { - return ''' + return r''' // 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 = {}; } @@ -26,6 +20,129 @@ _flutter.loader = null; (function() { "use strict"; + /** + * 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 loading/reloading Flutter's service worker, if configured. + * + * @see: https://developers.google.com/web/fundamentals/primers/service-workers + */ + class FlutterServiceWorkerLoader { + /** + * 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 (!("serviceWorker" in navigator) || settings == null) { + // In the future, settings = null -> uninstall service worker? + return Promise.reject(new Error("Service worker not supported (or configured).")); + } + const { + serviceWorkerVersion, + serviceWorkerUrl = "flutter_service_worker.js?v=" + serviceWorkerVersion, + timeoutMillis = 4000, + } = settings; + + const serviceWorkerActivation = navigator.serviceWorker.register(serviceWorkerUrl) + .then(this._getNewServiceWorker) + .then(this._waitForServiceWorkerActivation); + + // Timeout race promise + return timeout(serviceWorkerActivation, timeoutMillis, "prepareServiceWorker"); + } + + /** + * Returns the latest service worker for the given `serviceWorkerRegistrationPromise`. + * + * This might return the current service worker, if there's no new service worker + * awaiting to be installed/updated. + * + * @param {Promise} serviceWorkerRegistrationPromise + * @returns {Promise} + */ + async _getNewServiceWorker(serviceWorkerRegistrationPromise) { + const reg = await serviceWorkerRegistrationPromise; + + 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. + console.debug("Installing/Activating first service worker."); + return 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. + return reg.update().then((newReg) => { + console.debug("Updating service worker."); + return newReg.installing || newReg.waiting || newReg.active; + }); + } else { + console.debug("Loading from existing service worker."); + return reg.active; + } + } + + /** + * Returns a Promise that resolves when the `latestServiceWorker` changes its + * state to "activated". + * + * @param {Promise} latestServiceWorkerPromise + * @returns {Promise} + */ + async _waitForServiceWorkerActivation(latestServiceWorkerPromise) { + const serviceWorker = await latestServiceWorkerPromise; + + if (!serviceWorker || serviceWorker.state == "activated") { + if (!serviceWorker) { + return Promise.reject(new Error("Cannot activate a null service worker!")); + } else { + console.debug("Service worker already active."); + return Promise.resolve(); + } + } + return new Promise((resolve, _) => { + serviceWorker.addEventListener("statechange", () => { + if (serviceWorker.state == "activated") { + console.debug("Activated new service worker."); + resolve(); + } + }); + }); + } + } + class FlutterLoader { /** * Creates a FlutterLoader, and initializes its instance methods. @@ -40,6 +157,15 @@ _flutter.loader = null; // Resolver for the pending promise returned by loadEntrypoint. this._didCreateEngineInitializerResolve = null; + // TODO: Make FlutterLoader extend EventTarget once Safari is mature enough + // to support EventTarget() constructor. + // @see: https://caniuse.com/mdn-api_eventtarget_eventtarget + this._eventTarget = document.createElement("custom-event-target"); + + // The event of the synthetic CustomEvent that we use to signal that the + // entrypoint is loaded. + this._eventName = "flutter:entrypoint-loaded"; + // Called by Flutter web. // Bound to `this` now, so "this" is preserved across JS <-> Flutter jumps. this.didCreateEngineInitializer = this._didCreateEngineInitializer.bind(this); @@ -51,27 +177,53 @@ _flutter.loader = null; * @returns a Promise that will eventually resolve with an EngineInitializer, * or will be rejected with the error caused by the loader. */ - loadEntrypoint(options) { + async loadEntrypoint(options) { const { entrypointUrl = "main.dart.js", serviceWorker, } = (options || {}); - return this._loadWithServiceWorker(entrypointUrl, serviceWorker); + + try { + // This method could be injected as loadEntrypoint config instead, also + // dynamically imported when this becomes a ESModule. + await new FlutterServiceWorkerLoader().loadServiceWorker(serviceWorker); + } catch (e) { + // Regardless of what happens with the injection of the SW, the show must go on + console.warn(e); + } + // This method could also be configurable, to attach new load techniques + return this._loadEntrypoint(entrypointUrl); + } + + /** + * Registers a listener for the entrypoint-loaded events fired from this loader. + * + * @param {Function} entrypointLoadedCallback + * @returns {undefined} + */ + onEntrypointLoaded(entrypointLoadedCallback) { + this._eventTarget.addEventListener(this._eventName, entrypointLoadedCallback); + // Disable the promise resolution + this._didCreateEngineInitializerResolve = null; } /** - * Resolves the promise created by loadEntrypoint. + * Resolves the promise created by loadEntrypoint once, and dispatches an + * `this._eventName` event. + * * Called by Flutter through the public `didCreateEngineInitializer` method, * which is bound to the correct instance of the FlutterLoader on the page. - * @param {*} engineInitializer + * @param {Function} engineInitializer */ _didCreateEngineInitializer(engineInitializer) { - if (typeof this._didCreateEngineInitializerResolve != "function") { - console.warn("Do not call didCreateEngineInitializer by hand. Start with loadEntrypoint instead."); + if (typeof this._didCreateEngineInitializerResolve == "function") { + this._didCreateEngineInitializerResolve(engineInitializer); + // Remove the resolver after the first time, so Flutter Web can hot restart. + this._didCreateEngineInitializerResolve = null; } - this._didCreateEngineInitializerResolve(engineInitializer); - // Remove the public method after it's done, so Flutter Web can hot restart. - delete this.didCreateEngineInitializer; + this._eventTarget.dispatchEvent(new CustomEvent(this._eventName, { + detail: engineInitializer + })); } _loadEntrypoint(entrypointUrl) { @@ -83,8 +235,8 @@ _flutter.loader = null; scriptTag.type = "application/javascript"; // Cache the resolve, so it can be called from Flutter. // Note: Flutter hot restart doesn't re-create this promise, so this - // can only be called once. Instead, we need to model this as a stream - // of `engineCreated` events coming from Flutter that are handled by JS. + // can only be called once. Use onEngineInitialized for a stream of + // engineInitialized events, that handle hot restart better! this._didCreateEngineInitializerResolve = resolve; scriptTag.addEventListener("error", reject); document.body.append(scriptTag); @@ -93,81 +245,6 @@ _flutter.loader = null; return this._scriptLoaded; } - - _waitForServiceWorkerActivation(serviceWorker, entrypointUrl) { - if (!serviceWorker || serviceWorker.state == "activated") { - if (!serviceWorker) { - console.warn("Cannot activate a null service worker."); - } else { - console.debug("Service worker already active."); - } - return this._loadEntrypoint(entrypointUrl); - } - return new Promise((resolve, _) => { - serviceWorker.addEventListener("statechange", () => { - if (serviceWorker.state == "activated") { - console.debug("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).", serviceWorkerOptions); - return this._loadEntrypoint(entrypointUrl); - } - - const { - serviceWorkerVersion, - timeoutMillis = 4000, - } = serviceWorkerOptions; - - let 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. - let sw = reg.installing || reg.waiting; - return this._waitForServiceWorkerActivation(sw, 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.debug("New service worker available."); - return reg.update().then((reg) => { - console.debug("Service worker updated."); - let sw = reg.installing || reg.waiting || reg.active; - return this._waitForServiceWorkerActivation(sw, entrypointUrl); - }); - } else { - // Existing service worker is still good. - console.debug("Loading app from service worker."); - return this._loadEntrypoint(entrypointUrl); - } - }) - .catch((error) => { - // Some exception happened while registering/activating the service worker. - console.warn("Failed to register or activate service worker:", error); - return this._loadEntrypoint(entrypointUrl); - }); - - // Timeout race promise - let timeout; - if (timeoutMillis > 0) { - timeout = new Promise((resolve, _) => { - setTimeout(() => { - if (!this._scriptLoaded) { - console.warn("Loading from service worker timed out after", timeoutMillis, "milliseconds."); - resolve(this._loadEntrypoint(entrypointUrl)); - } - }, timeoutMillis); - }); - } - - return Promise.race([loader, timeout]); - } } _flutter.loader = new FlutterLoader(); From a608c801cf7b80850515d704aef26f88c30c080f Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 1 Aug 2022 18:14:49 -0700 Subject: [PATCH 2/4] Add integration test for new flutter.js API. --- dev/bots/service_worker_test.dart | 6 +++ dev/bots/test.dart | 2 + ...ndex_with_flutterjs_entrypoint_loaded.html | 41 +++++++++++++++++++ dev/md | 0 4 files changed, 49 insertions(+) create mode 100644 dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html create mode 100644 dev/md diff --git a/dev/bots/service_worker_test.dart b/dev/bots/service_worker_test.dart index 2f4d3b0a78e8a..ce148d28233ac 100644 --- a/dev/bots/service_worker_test.dart +++ b/dev/bots/service_worker_test.dart @@ -29,6 +29,7 @@ enum ServiceWorkerTestType { withoutFlutterJs, withFlutterJs, withFlutterJsShort, + withFlutterJsEntrypointLoadedEvent, } // Run a web service worker test as a standalone Dart program. @@ -36,9 +37,11 @@ Future main() async { await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withoutFlutterJs); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJs); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); + await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withoutFlutterJs); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJs); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); + await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent); await runWebServiceWorkerTestWithBlockedServiceWorkers(headless: false); } @@ -67,6 +70,9 @@ String _testTypeToIndexFile(ServiceWorkerTestType type) { case ServiceWorkerTestType.withFlutterJsShort: indexFile = 'index_with_flutterjs_short.html'; break; + case ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent: + indexFile = 'index_with_flutterjs_entrypoint_loaded.html'; + break; } return indexFile; } diff --git a/dev/bots/test.dart b/dev/bots/test.dart index cbb1bf6ed43c3..b8913fc38e806 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -1092,9 +1092,11 @@ Future _runWebLongRunningTests() async { () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJs), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort), + () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJs), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort), + () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent), () => runWebServiceWorkerTestWithBlockedServiceWorkers(headless: true), () => _runWebStackTraceTest('profile', 'lib/stack_trace.dart'), () => _runWebStackTraceTest('release', 'lib/stack_trace.dart'), diff --git a/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html b/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html new file mode 100644 index 0000000000000..9f25876f54c30 --- /dev/null +++ b/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html @@ -0,0 +1,41 @@ + + + + + + + + Codestin Search App + + + + + + + + + + + + + diff --git a/dev/md b/dev/md new file mode 100644 index 0000000000000..e69de29bb2d1d From a1ea0c575b0945fdcf551a593f30e1464d7fa1b9 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 2 Aug 2022 17:42:01 -0700 Subject: [PATCH 3/4] Address PR comments. Extract FlutterEntrypointLoader to its own class. FlutterLoader is now super nice and tiny! --- ...ndex_with_flutterjs_entrypoint_loaded.html | 7 +- .../src/web/file_generators/flutter_js.dart | 237 +++++++++++------- 2 files changed, 150 insertions(+), 94 deletions(-) diff --git a/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html b/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html index 9f25876f54c30..a364e597103f3 100644 --- a/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html +++ b/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html @@ -25,16 +25,17 @@ window.addEventListener('load', function(ev) { // Download main.dart.js _flutter.loader.loadEntrypoint({ + onEntrypointLoaded: onEntrypointLoaded, serviceWorker: { serviceWorkerVersion: serviceWorkerVersion, } }); + // Once the entrypoint is ready, do things! - _flutter.loader.onEntrypointLoaded(async function(event) { - const engineInitializer = event.detail; + async function onEntrypointLoaded(engineInitializer) { const appRunner = await engineInitializer.initializeEngine(); appRunner.runApp(); - }); + } }); diff --git a/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart b/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart index 24ff3c044bc54..bde1792686e00 100644 --- a/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart +++ b/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart @@ -18,7 +18,7 @@ if (!_flutter) { } _flutter.loader = null; -(function() { +(function () { "use strict"; /** * Wraps `promise` in a timeout of the given `duration` in ms. @@ -27,27 +27,32 @@ _flutter.loader = null; * 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. + * 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) { + if (duration < 0) { return promise; } - let _timeoutId; + let timeoutId; const _clock = new Promise((_, reject) => { - _timeoutId = setTimeout(() => { - reject(new Error(`${debugName} took more than ${duration}ms to resolve. Moving on.`, { - cause: timeout, - })); + 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); + clearTimeout(timeoutId); }); } @@ -68,20 +73,28 @@ _flutter.loader = null; loadServiceWorker(settings) { if (!("serviceWorker" in navigator) || settings == null) { // In the future, settings = null -> uninstall service worker? - return Promise.reject(new Error("Service worker not supported (or configured).")); + return Promise.reject( + new Error("Service worker not supported (or configured).") + ); } const { serviceWorkerVersion, - serviceWorkerUrl = "flutter_service_worker.js?v=" + serviceWorkerVersion, + serviceWorkerUrl = "flutter_service_worker.js?v=" + + serviceWorkerVersion, timeoutMillis = 4000, } = settings; - const serviceWorkerActivation = navigator.serviceWorker.register(serviceWorkerUrl) + const serviceWorkerActivation = navigator.serviceWorker + .register(serviceWorkerUrl) .then(this._getNewServiceWorker) .then(this._waitForServiceWorkerActivation); - // Timeout race promise - return timeout(serviceWorkerActivation, timeoutMillis, "prepareServiceWorker"); + // Timeout race promise + return timeout( + serviceWorkerActivation, + timeoutMillis, + "prepareServiceWorker" + ); } /** @@ -126,7 +139,9 @@ _flutter.loader = null; if (!serviceWorker || serviceWorker.state == "activated") { if (!serviceWorker) { - return Promise.reject(new Error("Cannot activate a null service worker!")); + return Promise.reject( + new Error("Cannot activate a null service worker!") + ); } else { console.debug("Service worker already active."); return Promise.resolve(); @@ -143,111 +158,151 @@ _flutter.loader = null; } } - class FlutterLoader { + /** + * 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 FlutterLoader, and initializes its instance methods. + * Creates a FlutterEntrypointLoader. */ constructor() { - // 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. - this._scriptLoaded = null; - - // Resolver for the pending promise returned by loadEntrypoint. - this._didCreateEngineInitializerResolve = null; - - // TODO: Make FlutterLoader extend EventTarget once Safari is mature enough - // to support EventTarget() constructor. - // @see: https://caniuse.com/mdn-api_eventtarget_eventtarget - this._eventTarget = document.createElement("custom-event-target"); - - // The event of the synthetic CustomEvent that we use to signal that the - // entrypoint is loaded. - this._eventName = "flutter:entrypoint-loaded"; - - // Called by Flutter web. - // Bound to `this` now, so "this" is preserved across JS <-> Flutter jumps. - this.didCreateEngineInitializer = this._didCreateEngineInitializer.bind(this); + this._scriptLoaded = false; } /** - * Initializes the main.dart.js with/without serviceWorker. + * 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 a Promise that will eventually resolve with an EngineInitializer, - * or will be rejected with the error caused by the loader. + * @returns {Promise?} that will eventually resolve with an EngineInitializer, + * or will be rejected with the error caused by the loader. If the user supplies + * an `onEntrypointLoaded` callback, this returns null. */ async loadEntrypoint(options) { - const { - entrypointUrl = "main.dart.js", - serviceWorker, - } = (options || {}); - - try { - // This method could be injected as loadEntrypoint config instead, also - // dynamically imported when this becomes a ESModule. - await new FlutterServiceWorkerLoader().loadServiceWorker(serviceWorker); - } catch (e) { - // Regardless of what happens with the injection of the SW, the show must go on - console.warn(e); - } - // This method could also be configurable, to attach new load techniques - return this._loadEntrypoint(entrypointUrl); - } + const { entrypointUrl = "main.dart.js", onEntrypointLoaded } = + options || {}; - /** - * Registers a listener for the entrypoint-loaded events fired from this loader. - * - * @param {Function} entrypointLoadedCallback - * @returns {undefined} - */ - onEntrypointLoaded(entrypointLoadedCallback) { - this._eventTarget.addEventListener(this._eventName, entrypointLoadedCallback); - // Disable the promise resolution - this._didCreateEngineInitializerResolve = null; + return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded); } /** - * Resolves the promise created by loadEntrypoint once, and dispatches an - * `this._eventName` event. + * Resolves the promise created by loadEntrypoint, and calls the `onEntrypointLoaded` + * function supplied by the user (if needed). * - * Called by Flutter through the public `didCreateEngineInitializer` method, - * which is bound to the correct instance of the FlutterLoader on the page. - * @param {Function} engineInitializer + * 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") { + 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; } - this._eventTarget.dispatchEvent(new CustomEvent(this._eventName, { - detail: engineInitializer - })); + if (typeof this._onEntrypointLoaded === "function") { + this._onEntrypointLoaded(engineInitializer); + } } - _loadEntrypoint(entrypointUrl) { + /** + * 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?} a promise that resolves when the entrypoint is loaded + * (if `onEntrypointLoaded` is not a function), or null. + */ + _loadEntrypoint(entrypointUrl, onEntrypointLoaded) { + const useCallback = typeof onEntrypointLoaded === "function"; + if (!this._scriptLoaded) { - console.debug("Injecting