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.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
this.estimateStorageUsage = estimateStorageUsage; this.estimateStorageUsage = estimateStorageUsage;
if (typeof fetch === "function") { if (typeof fetch === "function") {
this.request = createFetchRequest(this.clock.createTimeout); this.request = createFetchRequest(this.clock.createTimeout, this._serviceWorkerHandler);
} else { } else {
this.request = xhrRequest; this.request = xhrRequest;
} }

View File

@ -26,6 +26,7 @@ export class ServiceWorkerHandler {
this._registration = null; this._registration = null;
this._registrationPromise = null; this._registrationPromise = null;
this._currentController = null; this._currentController = null;
this.haltRequests = false;
} }
setNavigation(navigation) { setNavigation(navigation) {
@ -39,10 +40,12 @@ export class ServiceWorkerHandler {
this._registration = await navigator.serviceWorker.register(path); this._registration = await navigator.serviceWorker.register(path);
await navigator.serviceWorker.ready; await navigator.serviceWorker.ready;
this._currentController = navigator.serviceWorker.controller; this._currentController = navigator.serviceWorker.controller;
this._registrationPromise = null;
console.log("Service Worker registered");
this._registration.addEventListener("updatefound", this); 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(() => { this._closeSessionIfNeeded(sessionId).finally(() => {
event.source.postMessage({replyTo: data.id}); 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() { async _proposeUpdate() {
// we don't do confirm when the tab is hidden because it will block the event loop and prevent if (document.hidden) {
// events from the service worker to be processed (like controllerchange when the visible tab applies the update). return;
if (!document.hidden && this._registration.waiting && this._registration.active) { }
this._registration.waiting.removeEventListener("statechange", this); const version = await this._sendAndWaitForReply("version");
const version = await this._sendAndWaitForReply("version", null, this._registration.waiting); if (confirm(`Version ${version.version} (${version.buildHash}) is available. Reload to apply?`)) {
if (confirm(`Version ${version.version} (${version.buildHash}) is ready to install. Apply now?`)) { // prevent any fetch requests from going to the service worker
this._registration.waiting.postMessage({type: "skipWaiting"}); // will trigger controllerchange event // 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; break;
case "updatefound": case "updatefound":
this._registration.installing.addEventListener("statechange", this); this._registration.installing.addEventListener("statechange", this);
this._tryActivateUpdate();
break; break;
case "statechange": case "statechange": {
this._tryActivateUpdate(); if (event.target.state === "installed") {
this._proposeUpdate();
event.target.removeEventListener("statechange", this);
}
break; break;
}
case "controllerchange": case "controllerchange":
if (!this._currentController) { if (!this._currentController) {
// Clients.claim() in the SW can trigger a controllerchange event // Clients.claim() in the SW can trigger a controllerchange event
@ -115,7 +129,7 @@ export class ServiceWorkerHandler {
} else { } else {
// active service worker changed, // active service worker changed,
// refresh, so we can get all assets // 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 // up to date from it
document.location.reload(); 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) { 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 // fetch doesn't do upload progress yet, delegate to xhr
if (requestOptions?.uploadProgress) { if (requestOptions?.uploadProgress) {
return xhrRequest(url, requestOptions); 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() { async function purgeOldCaches() {
// remove any caches we don't know about // remove any caches we don't know about
const keyList = await caches.keys(); 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) => { self.addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request)); event.respondWith(handleRequest(event.request));
}); });
@ -85,9 +83,11 @@ function isCacheableThumbnail(url) {
} }
const baseURL = new URL(self.registration.scope); const baseURL = new URL(self.registration.scope);
let pendingFetchAbortController = new AbortController();
async function handleRequest(request) { async function handleRequest(request) {
try { try {
const url = new URL(request.url); const url = new URL(request.url);
// rewrite / to /index.html so it hits the cache
if (url.origin === baseURL.origin && url.pathname === baseURL.pathname) { if (url.origin === baseURL.origin && url.pathname === baseURL.pathname) {
request = new Request(new URL("index.html", baseURL.href)); 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 // 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 // https://developers.google.com/web/tools/chrome-devtools/progressive-web-apps?utm_source=devtools#opaque-responses
if (isCacheableThumbnail(url)) { if (isCacheableThumbnail(url)) {
response = await fetch(request, {mode: "cors", credentials: "omit"}); response = await fetch(request, {signal: pendingFetchAbortController.signal, mode: "cors", credentials: "omit"});
} else { } else {
response = await fetch(request); response = await fetch(request, {signal: pendingFetchAbortController.signal});
} }
await updateCache(request, response); await updateCache(request, response);
} }
return response; return response;
} catch (err) { } catch (err) {
if (!(err instanceof TypeError)) { if (err.name !== "TypeError" && err.name !== "AbortError") {
console.error("error in service worker", err); console.error("error in service worker", err);
} }
throw err; throw err;
@ -172,6 +172,9 @@ self.addEventListener('message', (event) => {
case "skipWaiting": case "skipWaiting":
self.skipWaiting(); self.skipWaiting();
break; break;
case "haltRequests":
event.waitUntil(haltRequests().then(() => reply()));
break;
case "closeSession": case "closeSession":
event.waitUntil( event.waitUntil(
closeSession(event.data.payload.sessionId, event.source.id) 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(); const pendingReplies = new Map();
let messageIdCounter = 0; let messageIdCounter = 0;
function sendAndWaitForReply(client, type, payload) { function sendAndWaitForReply(client, type, payload) {