halt any fetch request while waiting for new service worker to activate

this make updates apply instantly rather than sometimes being stalled
for seconds or minutes.
This commit is contained in:
Bruno Windels 2021-03-18 19:34:41 +01:00
parent 25cf72a9b6
commit 5d71b655ad
4 changed files with 64 additions and 30 deletions

View file

@ -107,7 +107,7 @@ export class Platform {
this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
this.estimateStorageUsage = estimateStorageUsage;
if (typeof fetch === "function") {
this.request = createFetchRequest(this.clock.createTimeout);
this.request = createFetchRequest(this.clock.createTimeout, this._serviceWorkerHandler);
} else {
this.request = xhrRequest;
}

View file

@ -26,6 +26,7 @@ export class ServiceWorkerHandler {
this._registration = null;
this._registrationPromise = null;
this._currentController = null;
this.haltRequests = false;
}
setNavigation(navigation) {
@ -39,10 +40,12 @@ export class ServiceWorkerHandler {
this._registration = await navigator.serviceWorker.register(path);
await navigator.serviceWorker.ready;
this._currentController = navigator.serviceWorker.controller;
this._registrationPromise = null;
console.log("Service Worker registered");
this._registration.addEventListener("updatefound", this);
this._tryActivateUpdate();
this._registrationPromise = null;
if (this._registration.waiting) {
this._proposeUpdate();
}
console.log("Service Worker registered");
})();
}
@ -61,6 +64,10 @@ export class ServiceWorkerHandler {
this._closeSessionIfNeeded(sessionId).finally(() => {
event.source.postMessage({replyTo: data.id});
});
} else if (data.type === "haltRequests") {
// this flag is read in fetch.js
this.haltRequests = true;
event.source.postMessage({replyTo: data.id});
}
}
@ -82,15 +89,19 @@ export class ServiceWorkerHandler {
}
}
async _tryActivateUpdate() {
// we don't do confirm when the tab is hidden because it will block the event loop and prevent
// events from the service worker to be processed (like controllerchange when the visible tab applies the update).
if (!document.hidden && this._registration.waiting && this._registration.active) {
this._registration.waiting.removeEventListener("statechange", this);
const version = await this._sendAndWaitForReply("version", null, this._registration.waiting);
if (confirm(`Version ${version.version} (${version.buildHash}) is ready to install. Apply now?`)) {
this._registration.waiting.postMessage({type: "skipWaiting"}); // will trigger controllerchange event
}
async _proposeUpdate() {
if (document.hidden) {
return;
}
const version = await this._sendAndWaitForReply("version");
if (confirm(`Version ${version.version} (${version.buildHash}) is available. Reload to apply?`)) {
// prevent any fetch requests from going to the service worker
// from any client, so that it is not kept active
// when calling skipWaiting on the new one
await this._sendAndWaitForReply("haltRequests");
// only once all requests are blocked, ask the new
// service worker to skipWaiting
this._send("skipWaiting", null, this._registration.waiting);
}
}
@ -101,11 +112,14 @@ export class ServiceWorkerHandler {
break;
case "updatefound":
this._registration.installing.addEventListener("statechange", this);
this._tryActivateUpdate();
break;
case "statechange":
this._tryActivateUpdate();
case "statechange": {
if (event.target.state === "installed") {
this._proposeUpdate();
event.target.removeEventListener("statechange", this);
}
break;
}
case "controllerchange":
if (!this._currentController) {
// Clients.claim() in the SW can trigger a controllerchange event
@ -115,7 +129,7 @@ export class ServiceWorkerHandler {
} else {
// active service worker changed,
// refresh, so we can get all assets
// (and not some if we would not refresh)
// (and not only some if we would not refresh)
// up to date from it
document.location.reload();
}

View file

@ -51,8 +51,15 @@ class RequestResult {
}
}
export function createFetchRequest(createTimeout) {
export function createFetchRequest(createTimeout, serviceWorkerHandler) {
return function fetchRequest(url, requestOptions) {
if (serviceWorkerHandler?.haltRequests) {
// prevent any requests while waiting
// for the new service worker to get activated.
// Once this happens, the page will be reloaded
// by the serviceWorkerHandler so this is fine.
return new RequestResult(new Promise(() => {}), {});
}
// fetch doesn't do upload progress yet, delegate to xhr
if (requestOptions?.uploadProgress) {
return xhrRequest(url, requestOptions);

View file

@ -37,6 +37,13 @@ self.addEventListener('install', function(e) {
})());
});
self.addEventListener('activate', (event) => {
// on a first page load/sw install,
// start using the service worker on all pages straight away
self.clients.claim();
event.waitUntil(purgeOldCaches());
});
async function purgeOldCaches() {
// remove any caches we don't know about
const keyList = await caches.keys();
@ -60,15 +67,6 @@ async function purgeOldCaches() {
}
}
self.addEventListener('activate', (event) => {
event.waitUntil(Promise.all([
purgeOldCaches(),
// on a first page load/sw install,
// start using the service worker on all pages straight away
self.clients.claim()
]));
});
self.addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});
@ -85,9 +83,11 @@ function isCacheableThumbnail(url) {
}
const baseURL = new URL(self.registration.scope);
let pendingFetchAbortController = new AbortController();
async function handleRequest(request) {
try {
const url = new URL(request.url);
// rewrite / to /index.html so it hits the cache
if (url.origin === baseURL.origin && url.pathname === baseURL.pathname) {
request = new Request(new URL("index.html", baseURL.href));
}
@ -96,15 +96,15 @@ async function handleRequest(request) {
// use cors so the resource in the cache isn't opaque and uses up to 7mb
// https://developers.google.com/web/tools/chrome-devtools/progressive-web-apps?utm_source=devtools#opaque-responses
if (isCacheableThumbnail(url)) {
response = await fetch(request, {mode: "cors", credentials: "omit"});
response = await fetch(request, {signal: pendingFetchAbortController.signal, mode: "cors", credentials: "omit"});
} else {
response = await fetch(request);
response = await fetch(request, {signal: pendingFetchAbortController.signal});
}
await updateCache(request, response);
}
return response;
} catch (err) {
if (!(err instanceof TypeError)) {
if (err.name !== "TypeError" && err.name !== "AbortError") {
console.error("error in service worker", err);
}
throw err;
@ -172,6 +172,9 @@ self.addEventListener('message', (event) => {
case "skipWaiting":
self.skipWaiting();
break;
case "haltRequests":
event.waitUntil(haltRequests().then(() => reply()));
break;
case "closeSession":
event.waitUntil(
closeSession(event.data.payload.sessionId, event.source.id)
@ -192,6 +195,16 @@ async function closeSession(sessionId, requestingClientId) {
}));
}
async function haltRequests() {
// first ask all clients to block sending any more requests
const clients = await self.clients.matchAll({type: "window"});
await Promise.all(clients.map(client => {
return sendAndWaitForReply(client, "haltRequests");
}));
// and only then abort the current requests
pendingFetchAbortController.abort();
}
const pendingReplies = new Map();
let messageIdCounter = 0;
function sendAndWaitForReply(client, type, payload) {