service worker doesn't skip waiting state - javascript

I'm still doing experiments in order to master service workers, and I'm facing a problem, probably because of my lack of expertise in JavaScript and service workers.
The problem happens when I want the new service worker to skipWaiting() using postMessage(). If I show a popup with a button and I bind a call to postMessage() there, everything works. If I call postMessage() directly, it doesn't work. It's a race condition because SOMETIMES it works, but I can't identify the race condition.
BTW, the postMessage() call WORKS, the service worker is logging what it should when getting the message:
// Listen to messages from clients.
self.addEventListener('message', event => {
switch(event.data) {
case 'skipWaiting': self.skipWaiting(); console.log('I skipped waiting... EXTRA');
break;
}
});
Here is the code. The important bit is on the if (registration.waiting) conditional. The uncommented code works, the commented one doesn't:
// Register service worker.
if ('serviceWorker' in navigator) {
// Helpers to show and hide the update toast.
let hideUpdateToast = () => {
document.getElementById('update_available').style.visibility = 'hidden';
};
let showUpdateToast = (serviceworker) => {
document.getElementById('update_available').style.visibility = 'visible';
document.getElementById('force_install').onclick = () => {
serviceworker.postMessage('skipWaiting');
hideUpdateToast();
};
document.getElementById('close').onclick = () => hideUpdateToast();
};
window.addEventListener('load', () => {
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
navigator.serviceWorker.register('/sw.js').then(registration => {
// A new service worker has been fetched, watch for state changes.
//
// This event is fired EVERY TIME a service worker is fetched and
// succesfully parsed and goes into 'installing' state. This
// happens, too, the very first time the page is visited, the very
// first time a service worker is fetched for this page, when the
// page doesn't have a controller, but in that case there's no new
// version available and the notification must not appear.
//
// So, if the page doesn't have a controller, no notification shown.
registration.addEventListener('updatefound', () => {
// return; // FIXME
registration.installing.onstatechange = function () { // No arrow function because 'this' is needed.
if (this.state == 'installed') {
if (!navigator.serviceWorker.controller) {
console.log('First install for this service worker.');
} else {
console.log('New service worker is ready to activate.');
showUpdateToast(this);
}
}
};
});
// If a service worker is in 'waiting' state, then maybe the user
// dismissed the notification when the service worker was in the
// 'installing' state or maybe the 'updatefound' event was fired
// before it could be listened, or something like that. Anyway, in
// that case the notification has to be shown again.
//
if (registration.waiting) {
console.log('New service worker is waiting.');
// showUpdateToast(registration.waiting);
// The above works, but this DOESN'T WORK.
registration.waiting.postMessage('skipWaiting');
}
}).catch(error => {
console.log('Service worker registration failed!');
console.log(error);
});
});
}
Why does the indirect call using a button onclick event works, but calling postMessage() doesn't?
I'm absolutely at a loss and I bet the answer is simple and I'm just too blind to see it.
Thanks a lot in advance.

Looks like a bug in Chromium or WebKit, because this code works all the time in Firefox but fails in Chrome and Edge most of the time.
I've reported that bug to Chromium, let's see if it is a bug or my code is weird. I've managed to build the smallest code possible that still reproduces the issue, it's quite small and it can be found on the bug report.
The report can be found here.
Sorry for the noise, I'll keep investigating the issue but no matter how I tried, the code still fails from time to time, I can't spot the race condition on my code.

Related

Why my Service Worker is always waiting to activate?

I have this very basic question
I'm striving to understand the Service Worker life cycle, or even better, what in practical terms initialize and change the states.
I got 2 questions right now:
1 - in chrome://inspect/#service-workers there are always 2 ou 3 lines, showing service workers all running with the same PID. Why? Why not only one?
2- When i inspect my service worker on refresh i got this:
#566 activated and is running [stop]
#570 waiting to activate [skipWaiting]
What does that mean? What is 566 and what is 570? I suppose they are instances of the the sw, but why there are two of them? And why 570 is still waiting? What do I have to do to make sure it will be registered-installed-activated?
3- General questions
What ends the install event in a normal life cycle?
What fires the activate event in a normal life cycle?
index.html
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('./sw.js')
.then(function(registration) {
// successful
console.log('Success: ', registration);
}).catch(function(err) {
// registration failed
console.log('Error: ', err);
});
});
}
</script>
sw.js
var cache_name = 'v1';
var cache_files = [
'./',
'./index.html',
'./style.css'
]
self.addEventListener('install', function(e){
console.log('SW install:', e);
e.waitUntil(
caches.open(cache_name)
.then(function(cache){
console.log('cache', cache);
return cache.addAll(cache_files);
})
.then(function(cache){
console.log('Cache completed');
})
)
});
self.addEventListener('activate', function(event) {
console.log('SW activate:', event);
});
self.addEventListener('fetch', function(e){
console.log('SW fetch:', e.request.url)
e.respondWith(
caches.match(e.request)
.then(function(cache_response){
if(cache_response) return cache_response;
return fetch(e.request);
})
.catch(function(err){
console.log('Cache error', err)
})
);
});
Thanks!
The ids shown by Chrome Devtools are internal. Just to point out. So they name all the Service Workers by an id. That's all.
The reason for having two SWs at the "same time" is that you had one, then you reloaded the page, navigated away and came back, or something along those lines, and you got another one. But at this point in time, when you "just got another one", it has yet to be activated and the previous SW is still controlling the page. The new SW will take control over the previous SW when you navigate back to the site from somewhere else, refreshing the page isn't enough. Basically this means closing all tabs and windows of the page and then loading it again, then the new SW takes over.
The time when the new SW hasn't taken over is called waiting state which happens between installation and activation. That can be skipped by calling self.skipWaiting() from inside the install handler of the SW.
The basic idea behind this flow is the page shouldn't be controlled by a SW that didn't control the page when the page was loaded – for this reason the first visit to a site that registers an SW will not be controlled by that SW, only the second time the SW will be activated etc.
You should REALLY read this brilliant article: The Service Worker Lifecycle

Service Worker "fetch" event for requesting the root path never fires

I am trying to set up my Service Worker so that it intercepts the request for the home page (ie the home page, like www.mywebsite.com/), which would eventually allow me to return a cached template instead. My code looks like this so far:
main.js:
navigator.serviceWorker.register('/sw.js')
sw.js:
self.addEventListener('fetch', function(event) {
console.log(event.request.url)
/**
* only ever detects requests for resources but never the route itself..
*
* logged:
* https://www.mywebsite.com/main.js
* https://www.mywebsite.com/myimage.png
*
* not logged:
* https://www.mywebsite.com/
*/
})
I'm pretty sure the service worker is getting set up correctly, as I am indeed detecting events being fired for requests for resources (like /main.js or /myimage.png). However, the problem is that only the resources' events ever get fired, even though I'd like that event for requesting the route itself (/) to be fired. Am I missing anything, or should I be listening for a different event?
Thanks
I discovered that the request for the root path was in fact getting intercepted. The reason I never noticed this is because the request happens before the page is loaded (ie before there's even a console to log to). If I turn on the Preserve log feature in Chrome DevTools, I will notice the logs for the root path requests as I should.
You can chain .then() to. .register() call then check location.href to determine the page at which the ServiceWorker has been registered
navigator.serviceWorker.register("sw.js")
.then(function(reg) {
if (location.href === "https://www.mywebsite.com/") {
// do stuff
console.log(location.href, reg.scope);
}
})
.catch(function(err) {
console.error(err);
});
I just figured this out, with help from https://livebook.manning.com/book/progressive-web-apps/chapter-4/27.
Here's a short version of my service worker:
const cacheName = "Cache_v1";
const urlsToCache = [
"Offline.html"
// styles, images and scripts
];
self.addEventListener('install', (e) =>
{
e.waitUntil(
caches.open(cacheName).then((cache) =>
{
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', (e) =>
{
if (e.request.url == "https://<DOMAIN_NAME>/")
{
console.log("root");
e.respondWith(
fetch("offline.html")
);
}
}

To check if ServiceWorker is in waiting state

I am trying to understand the Service Worker API, and I know the bits and parts about registering a Service Worker.
As stated in the API doc, if a service worker update is found, the service worker is registered and added to the queue. This SW takes over a page if and only if, the page is closed and opened again.That is, A window is closed and reopened again.
Now, this has a few downfalls:
The user might be seeing a previous version that might have a very serious grammatical mistake, or whatever.
The user needs to be somehow notified that the content has changed and that a referesh would do it.
I know how to tell the SW.js to skipWaiting() and take over. I also know how to send a message to the SW.js telling it that the user wants a automatic refresh.
However, what I do not know is how to know whether a new SW is actually in a waiting state.
I have used this:
navigator.serviceWorker.ready.then((a) => {
console.log("Response, ", a);
if (a.waiting !== null && a.waiting.state === "installed") {
console.log("okay");
}
});
However, it usually returns the waiting state as null.(Possibly because the SW is still installing when the request is fired.)
How can I know on the client page that a waiting service worker is available?
Here's some code that will detect and allow you to handle various states whenever there's a new or updated service worker registration.
Please note that the log message assumes that skipWaiting() is not being called during the service worker's installation; if it is being called, then instead of having to close all tabs to get the new service worker to activate, it will just activate automatically.
if ('serviceWorker' in navigator) {
window.addEventListener('load', async function() {
const registration = await navigator.serviceWorker.register('/service-worker.js');
if (registration.waiting && registration.active) {
// The page has been loaded when there's already a waiting and active SW.
// This would happen if skipWaiting() isn't being called, and there are
// still old tabs open.
console.log('Please close all tabs to get updates.');
} else {
// updatefound is also fired for the very first install. ¯\_(ツ)_/¯
registration.addEventListener('updatefound', () => {
registration.installing.addEventListener('statechange', () => {
if (event.target.state === 'installed') {
if (registration.active) {
// If there's already an active SW, and skipWaiting() is not
// called in the SW, then the user needs to close all their
// tabs before they'll get updates.
console.log('Please close all tabs to get updates.');
} else {
// Otherwise, this newly installed SW will soon become the
// active SW. Rather than explicitly wait for that to happen,
// just show the initial "content is cached" message.
console.log('Content is cached for the first time!');
}
}
});
});
}
});
}

ServiceWorkerContainer.ready for a specific scriptURL?

ServiceWorkerContainer.ready resolves to an active ServiceWorkerRegistration, but if there are multiple service workers in play (e.g. service workers from previous page loads, multiple registrations within the same page) there are multiple service workers it could resolve to. How can I make sure that a particular service worker is handling network events when a chunk of code is executed?
That is, how can I write a function registerReady(scriptURL, options) that can be used as follows:
registerReady("foo.js").then(function (r) {
// "foo.js" is active and r.active === navigator.serviceWorker.controller
fetch("/"); // uses foo.js's "fetch" handler
});
registerReady("bar.js").then(function (r) {
// "bar.js" is active and r.active === navigator.serviceWorker.controller
fetch("/"); // use bar.js's "fetch" handler
});
First, notice there can be only one active service worker per scope and your client page can be controlled by at most one and only one service worker. So, when ready resolves, you know for sure the service worker controlling your page is active.
To know if an arbitrary sw is active you can register it and check the queue of service workers in the registration and listen for changes in their state. For instance:
function registerReady(swScript, options) {
return navigator.serviceWorker.register(swScript, options)
.then(reg => {
// If there is an active worker and nothing incoming, we are done.
var incomingSw = reg.installing || reg.waiting;
if (reg.active && !incomingSw) {
return Promise.resolve();
}
// If not, wait for the newest service worker to become activated.
return new Promise(fulfill => {
incomingSw.onstatechange = evt => {
if (evt.target.state === 'activated') {
incomingSw.onstatechange = null;
return fulfill();
}
};
});
})
}
Hope it makes sense to you.

Catch Error from gapi.client.load

I'm using Google App Engine with Java and Google Cloud Endpoints. In my JavaScript front end, I'm using this code to handle initialization, as recommended:
var apisToLoad = 2;
var url = '//' + $window.location.host + '/_ah/api';
gapi.client.load('sd', 'v1', handleLoad, url);
gapi.client.load('oauth2', 'v2', handleLoad);
function handleLoad() {
// this only executes once,
if (--apisToLoad === 0) {
// so this is not executed
}
}
How can I detect and handle when gapi.client.load fails? Currently I am getting an error printed to the JavaScript console that says: Could not fetch URL: https://webapis-discovery.appspot.com/_ah/api/static/proxy.html). Maybe that's my fault, or maybe it's a temporary problem on Google's end - right now that is not my concern. I'm trying to take advantage of this opportunity to handle such errors well on the client side.
So - how can I handle it? handleLoad is not executed for the call that errs, gapi.client.load does not seem to have a separate error callback (see the documentation), it does not actually throw the error (only prints it to the console), and it does not return anything. What am I missing? My only idea so far is to set a timeout and assume there was an error if initialization doesn't complete after X seconds, but that is obviously less than ideal.
Edit:
This problem came up again, this time with the message ERR_CONNECTION_TIMED_OUT when trying to load the oauth stuff (which is definitely out of my control). Again, I am not trying to fix the error, it just confirms that it is worth detecting and handling gracefully.
I know this is old but I came across this randomly. You can easily test for a fail (at least now).
Here is the code:
gapi.client.init({}).then(() => {
gapi.client.load('some-api', "v1", (err) => { callback(err) }, "https://someapi.appspot.com/_ah/api");
}, err, err);
function callback(loadErr) {
if (loadErr) { err(loadErr); return; }
// success code here
}
function err(err){
console.log('Error: ', err);
// fail code here
}
Example
Unfortunately, the documentation is pretty useless here and it's not exactly easy to debug the code in question. What gapi.client.load() apparently does is inserting an <iframe> element for each API. That frame then provides the necessary functionality and allows accessing it via postMessage(). From the look of it, the API doesn't attach a load event listener to that frame and rather relies on the frame itself to indicate that it is ready (this will result in the callback being triggered). So the missing error callback is an inherent issue - the API cannot see a failure because no frame will be there to signal it.
From what I can tell, the best thing you can do is attaching your own load event listener to the document (the event will bubble up from the frames) and checking yourself when they load. Warning: While this might work with the current version of the API, it is not guaranteed to continue working in future as the implementation of that API changes. Currently something like this should work:
var framesToLoad = apisToLoad;
document.addEventListener("load", function(event)
{
if (event.target.localName == "iframe")
{
framesToLoad--;
if (framesToLoad == 0)
{
// Allow any outstanding synchronous actions to execute, just in case
window.setTimeout(function()
{
if (apisToLoad > 0)
alert("All frames are done but not all APIs loaded - error?");
}, 0);
}
}
}, true);
Just to repeat the warning from above: this code makes lots of assumptions. While these assumptions might stay true for a while with this API, it might also be that Google will change something and this code will stop working. It might even be that Google uses a different approach depending on the browser, I only tested in Firefox.
This is an extremely hacky way of doing it, but you could intercept all console messages, check what is being logged, and if it is the error message you care about it, call another function.
function interceptConsole(){
var errorMessage = 'Could not fetch URL: https://webapis-discovery.appspot.com/_ah/api/static/proxy.html';
var console = window.console
if (!console) return;
function intercept(method){
var original = console[method];
console[method] = function() {
if (arguments[0] == errorMessage) {
alert("Error Occured");
}
if (original.apply){
original.apply(console, arguments)
}
else {
//IE
var message = Array.prototype.slice.apply(arguments).join(' ');
original(message)
}
}
}
var methods = ['log', 'warn', 'error']
for (var i = 0; i < methods.length; i++)
intercept(methods[i])
}
interceptConsole();
console.log('Could not fetch URL: https://webapis-discovery.appspot.com/_ah/api/static/proxy.html');
//alerts "Error Occured", then logs the message
console.log('Found it');
//just logs "Found It"
An example is here - I log two things, one is the error message, the other is something else. You'll see the first one cause an alert, the second one does not.
http://jsfiddle.net/keG7X/
You probably would have to run the interceptConsole function before including the gapi script as it may make it's own copy of console.
Edit - I use a version of this code myself, but just remembered it's from here, so giving credit where it's due.
I use a setTimeout to manually trigger error if the api hasn't loaded yet:
console.log(TAG + 'api loading...');
let timer = setTimeout(() => {
// Handle error
reject('timeout');
console.error(TAG + 'api loading error: timeout');
}, 1000); // time till timeout
let callback = () => {
clearTimeout(timer);
// api has loaded, continue your work
console.log(TAG + 'api loaded');
resolve(gapi.client.apiName);
};
gapi.client.load('apiName', 'v1', callback, apiRootUrl);

Categories