Unverified Commit 04f7ea84 authored by David Iglesias's avatar David Iglesias Committed by GitHub

[web] Add onEntrypointLoaded to FlutterLoader. (#108776)

parent f7b00234
...@@ -29,6 +29,7 @@ enum ServiceWorkerTestType { ...@@ -29,6 +29,7 @@ enum ServiceWorkerTestType {
withoutFlutterJs, withoutFlutterJs,
withFlutterJs, withFlutterJs,
withFlutterJsShort, withFlutterJsShort,
withFlutterJsEntrypointLoadedEvent,
} }
// Run a web service worker test as a standalone Dart program. // Run a web service worker test as a standalone Dart program.
...@@ -36,9 +37,11 @@ Future<void> main() async { ...@@ -36,9 +37,11 @@ Future<void> main() async {
await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withoutFlutterJs); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withoutFlutterJs);
await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJs); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJs);
await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); 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.withoutFlutterJs);
await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJs); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJs);
await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort);
await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent);
await runWebServiceWorkerTestWithBlockedServiceWorkers(headless: false); await runWebServiceWorkerTestWithBlockedServiceWorkers(headless: false);
} }
...@@ -67,6 +70,9 @@ String _testTypeToIndexFile(ServiceWorkerTestType type) { ...@@ -67,6 +70,9 @@ String _testTypeToIndexFile(ServiceWorkerTestType type) {
case ServiceWorkerTestType.withFlutterJsShort: case ServiceWorkerTestType.withFlutterJsShort:
indexFile = 'index_with_flutterjs_short.html'; indexFile = 'index_with_flutterjs_short.html';
break; break;
case ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent:
indexFile = 'index_with_flutterjs_entrypoint_loaded.html';
break;
} }
return indexFile; return indexFile;
} }
......
...@@ -1092,9 +1092,11 @@ Future<void> _runWebLongRunningTests() async { ...@@ -1092,9 +1092,11 @@ Future<void> _runWebLongRunningTests() async {
() => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs),
() => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJs), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJs),
() => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort),
() => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent),
() => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs),
() => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJs), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJs),
() => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort),
() => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent),
() => runWebServiceWorkerTestWithBlockedServiceWorkers(headless: true), () => runWebServiceWorkerTestWithBlockedServiceWorkers(headless: true),
() => _runWebStackTraceTest('profile', 'lib/stack_trace.dart'), () => _runWebStackTraceTest('profile', 'lib/stack_trace.dart'),
() => _runWebStackTraceTest('release', 'lib/stack_trace.dart'), () => _runWebStackTraceTest('release', 'lib/stack_trace.dart'),
......
<!DOCTYPE HTML>
<!-- 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. -->
<html>
<head>
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<title>Integration test. App load with flutter.js and onEntrypointLoaded API</title>
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<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>
<script>
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
onEntrypointLoaded: onEntrypointLoaded,
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
}
});
// Once the entrypoint is ready, do things!
async function onEntrypointLoaded(engineInitializer) {
const appRunner = await engineInitializer.initializeEngine();
appRunner.runApp();
}
});
</script>
</body>
</html>
...@@ -8,169 +8,301 @@ ...@@ -8,169 +8,301 @@
/// flutter.js should be completely static, so **do not use any parameter or /// flutter.js should be completely static, so **do not use any parameter or
/// environment variable to generate this file**. /// environment variable to generate this file**.
String generateFlutterJsFile() { String generateFlutterJsFile() {
return ''' return r'''
// Copyright 2014 The Flutter Authors. All rights reserved. // Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // 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) { if (!_flutter) {
var _flutter = {}; var _flutter = {};
} }
_flutter.loader = null; _flutter.loader = null;
(function() { (function () {
"use strict"; "use strict";
class FlutterLoader { /**
/** * Wraps `promise` in a timeout of the given `duration` in ms.
* Creates a FlutterLoader, and initializes its instance methods. *
*/ * Resolves/rejects with whatever the original `promises` does, or rejects
constructor() { * if `promise` takes longer to complete than `duration`. In that case,
// TODO: Move the below methods to "#private" once supported by all the browsers * `debugName` is used to compose a legible error message.
// we support. In the meantime, we use the "revealing module" pattern. *
* If `duration` is < 0, the original `promise` is returned unchanged.
// Watchdog to prevent injecting the main entrypoint multiple times. * @param {Promise} promise
this._scriptLoaded = null; * @param {number} duration
* @param {string} debugName
// Resolver for the pending promise returned by loadEntrypoint. * @returns {Promise} a wrapped promise.
this._didCreateEngineInitializerResolve = null; */
async function timeout(promise, duration, debugName) {
// Called by Flutter web. if (duration < 0) {
// Bound to `this` now, so "this" is preserved across JS <-> Flutter jumps. return promise;
this.didCreateEngineInitializer = this._didCreateEngineInitializer.bind(this);
} }
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 {
/** /**
* Initializes the main.dart.js with/without serviceWorker. * Returns a Promise that resolves when the latest Flutter service worker,
* @param {*} options * configured by `settings` has been loaded and activated.
* @returns a Promise that will eventually resolve with an EngineInitializer, *
* or will be rejected with the error caused by the loader. * Otherwise, the promise is rejected with an error message.
* @param {*} settings Service worker settings
* @returns {Promise} that resolves when the latest serviceWorker is ready.
*/ */
loadEntrypoint(options) { 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 { const {
entrypointUrl = "main.dart.js", serviceWorkerVersion,
serviceWorker, serviceWorkerUrl = "flutter_service_worker.js?v=" +
} = (options || {}); serviceWorkerVersion,
return this._loadWithServiceWorker(entrypointUrl, serviceWorker); timeoutMillis = 4000,
} = settings;
const serviceWorkerActivation = navigator.serviceWorker
.register(serviceWorkerUrl)
.then(this._getNewServiceWorker)
.then(this._waitForServiceWorkerActivation);
// Timeout race promise
return timeout(
serviceWorkerActivation,
timeoutMillis,
"prepareServiceWorker"
);
} }
/** /**
* Resolves the promise created by loadEntrypoint. * Returns the latest service worker for the given `serviceWorkerRegistrationPromise`.
* Called by Flutter through the public `didCreateEngineInitializer` method, *
* which is bound to the correct instance of the FlutterLoader on the page. * This might return the current service worker, if there's no new service worker
* @param {*} engineInitializer * awaiting to be installed/updated.
*
* @param {Promise<ServiceWorkerRegistration>} serviceWorkerRegistrationPromise
* @returns {Promise<ServiceWorker>}
*/ */
_didCreateEngineInitializer(engineInitializer) { async _getNewServiceWorker(serviceWorkerRegistrationPromise) {
if (typeof this._didCreateEngineInitializerResolve != "function") { const reg = await serviceWorkerRegistrationPromise;
console.warn("Do not call didCreateEngineInitializer by hand. Start with loadEntrypoint instead.");
}
this._didCreateEngineInitializerResolve(engineInitializer);
// Remove the public method after it's done, so Flutter Web can hot restart.
delete this.didCreateEngineInitializer;
}
_loadEntrypoint(entrypointUrl) { if (!reg.active && (reg.installing || reg.waiting)) {
if (!this._scriptLoaded) { // No active web worker and we have installed or are installing
console.debug("Injecting <script> tag."); // one for the first time. Simply wait for it to activate.
this._scriptLoaded = new Promise((resolve, reject) => { console.debug("Installing/Activating first service worker.");
let scriptTag = document.createElement("script"); return reg.installing || reg.waiting;
scriptTag.src = entrypointUrl; } else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
scriptTag.type = "application/javascript"; // When the app updates the serviceWorkerVersion changes, so we
// Cache the resolve, so it can be called from Flutter. // need to ask the service worker to update.
// Note: Flutter hot restart doesn't re-create this promise, so this return reg.update().then((newReg) => {
// can only be called once. Instead, we need to model this as a stream console.debug("Updating service worker.");
// of `engineCreated` events coming from Flutter that are handled by JS. return newReg.installing || newReg.waiting || newReg.active;
this._didCreateEngineInitializerResolve = resolve;
scriptTag.addEventListener("error", reject);
document.body.append(scriptTag);
}); });
} else {
console.debug("Loading from existing service worker.");
return reg.active;
} }
return this._scriptLoaded;
} }
_waitForServiceWorkerActivation(serviceWorker, entrypointUrl) { /**
* Returns a Promise that resolves when the `latestServiceWorker` changes its
* state to "activated".
*
* @param {Promise<ServiceWorker>} latestServiceWorkerPromise
* @returns {Promise<void>}
*/
async _waitForServiceWorkerActivation(latestServiceWorkerPromise) {
const serviceWorker = await latestServiceWorkerPromise;
if (!serviceWorker || serviceWorker.state == "activated") { if (!serviceWorker || serviceWorker.state == "activated") {
if (!serviceWorker) { if (!serviceWorker) {
console.warn("Cannot activate a null service worker."); return Promise.reject(
new Error("Cannot activate a null service worker!")
);
} else { } else {
console.debug("Service worker already active."); console.debug("Service worker already active.");
return Promise.resolve();
} }
return this._loadEntrypoint(entrypointUrl);
} }
return new Promise((resolve, _) => { return new Promise((resolve, _) => {
serviceWorker.addEventListener("statechange", () => { serviceWorker.addEventListener("statechange", () => {
if (serviceWorker.state == "activated") { if (serviceWorker.state == "activated") {
console.debug("Installed new service worker."); console.debug("Activated new service worker.");
resolve(this._loadEntrypoint(entrypointUrl)); 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;
}
_loadWithServiceWorker(entrypointUrl, serviceWorkerOptions) { /**
if (!("serviceWorker" in navigator) || serviceWorkerOptions == null) { * Loads flutter main entrypoint, specified by `entrypointUrl`, and calls a
console.warn("Service worker not supported (or configured).", serviceWorkerOptions); * user-specified `onEntrypointLoaded` callback with an EngineInitializer
return this._loadEntrypoint(entrypointUrl); * 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 = "main.dart.js", onEntrypointLoaded } =
options || {};
return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded);
}
/**
* 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;
} }
if (typeof this._onEntrypointLoaded === "function") {
this._onEntrypointLoaded(engineInitializer);
}
}
const { /**
serviceWorkerVersion, * Injects a script tag into the DOM, and configures this loader to be able to
timeoutMillis = 4000, * handle the "entrypoint loaded" notifications received from Flutter web.
} = serviceWorkerOptions; *
* @param {string} entrypointUrl the URL of the script that will initialize
let serviceWorkerUrl = "flutter_service_worker.js?v=" + serviceWorkerVersion; * Flutter.
let loader = navigator.serviceWorker.register(serviceWorkerUrl) * @param {Function} onEntrypointLoaded a callback that will be called when
.then((reg) => { * Flutter web notifies this object that the entrypoint is
if (!reg.active && (reg.installing || reg.waiting)) { * loaded.
// No active web worker and we have installed or are installing * @returns {Promise | undefined} a Promise that resolves when the entrypoint
// one for the first time. Simply wait for it to activate. * is loaded, or undefined if `onEntrypointLoaded`
let sw = reg.installing || reg.waiting; * is a function.
return this._waitForServiceWorkerActivation(sw, entrypointUrl); */
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) { _loadEntrypoint(entrypointUrl, onEntrypointLoaded) {
// When the app updates the serviceWorkerVersion changes, so we const useCallback = typeof onEntrypointLoaded === "function";
// 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 if (!this._scriptLoaded) {
let timeout; this._scriptLoaded = true;
if (timeoutMillis > 0) { const scriptTag = this._createScriptTag(entrypointUrl);
timeout = new Promise((resolve, _) => { if (useCallback) {
setTimeout(() => { // Just inject the script tag, and return nothing; Flutter will call
if (!this._scriptLoaded) { // `didCreateEngineInitializer` when it's done.
console.warn("Loading from service worker timed out after", timeoutMillis, "milliseconds."); console.debug("Injecting <script> tag. Using callback.");
resolve(this._loadEntrypoint(entrypointUrl)); this._onEntrypointLoaded = onEntrypointLoaded;
} document.body.append(scriptTag);
}, timeoutMillis); } else {
}); // Inject the script tag and return a promise that will get resolved
// with the EngineInitializer object from Flutter when it calls
// `didCreateEngineInitializer` later.
return new Promise((resolve, reject) => {
console.debug(
"Injecting <script> tag. Using Promises. Use the callback approach instead!"
);
this._didCreateEngineInitializerResolve = resolve;
scriptTag.addEventListener("error", reject);
document.body.append(scriptTag);
});
}
} }
}
/**
* Creates a script tag for the given URL.
* @param {string} url
* @returns {HTMLScriptElement}
*/
_createScriptTag(url) {
const scriptTag = document.createElement("script");
scriptTag.type = "application/javascript";
scriptTag.src = url;
return scriptTag;
}
}
return Promise.race([loader, timeout]); /**
* The public interface of _flutter.loader. Exposes two methods:
* * loadEntrypoint (which coordinates the default Flutter web loading procedure)
* * didCreateEngineInitializer (which is called by Flutter to notify that its
* Engine is ready to be initialized)
*/
class FlutterLoader {
/**
* Initializes the Flutter web app.
* @param {*} options
* @returns {Promise?} a (Deprecated) Promise that will eventually resolve
* with an EngineInitializer, or will be rejected with
* any error caused by the loader. Or Null, if the user
* supplies an `onEntrypointLoaded` Function as an option.
*/
async loadEntrypoint(options) {
const { serviceWorker, ...entrypoint } = options || {};
// The FlutterServiceWorkerLoader instance could be injected as a dependency
// (and dynamically imported from a module if not present).
const serviceWorkerLoader = new FlutterServiceWorkerLoader();
await serviceWorkerLoader.loadServiceWorker(serviceWorker).catch(e => {
// Regardless of what happens with the injection of the SW, the show must go on
console.warn("Exception while loading service worker:", e);
});
// The FlutterEntrypointLoader instance could be injected as a dependency
// (and dynamically imported from a module if not present).
const entrypointLoader = new FlutterEntrypointLoader();
// Install the `didCreateEngineInitializer` listener where Flutter web expects it to be.
this.didCreateEngineInitializer =
entrypointLoader.didCreateEngineInitializer.bind(entrypointLoader);
return entrypointLoader.loadEntrypoint(entrypoint);
} }
} }
_flutter.loader = new FlutterLoader(); _flutter.loader = new FlutterLoader();
}()); })();
'''; ''';
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment