I have implemented Workbox to generate my service worker using webpack. This works - I can confirm revision is updated in the generated service worker when running "generate-sw": "workbox inject:manifest".
The problem is - I have noticed my clients are not updating the cache after a new release. Even days after updating the service worker my clients are still caching the old code and new code will only load after several refreshes and/or unregistering the service worker. For each release I have confirmed that the revision is updated.
I understand that I need to implement skipWaiting to ensure the clients gets updated - especially PWA. I have read, and tried to follow the 3rd approach here: https://redfin.engineering/how-to-fix-the-refresh-button-when-using-service-workers-a8e27af6df68.
My app mounts in app.js
I have added this code to
serviceWorker-base.js
addEventListener('message', function(messageEvent){
if (messageEvent.data === 'skipWaiting') return skipWaiting();
});
I have this code in app.js
const runServiceWorker = true
const serviceWorkerAvailable = ('serviceWorker' in navigator) ? true : false
// reload once when the new Service Worker starts activating
let refreshing
navigator.serviceWorker.addEventListener('controllerchange', function() {
if (refreshing) return
refreshing = true
window.location.reload()
}
)
function promptUserToRefresh(reg) {
// this is just an example - don't use window.confirm in real life; it's terrible
if (window.confirm("New version available! OK to refresh?")) {
reg.waiting.postMessage('skipWaiting')
}
}
function listenForWaitingServiceWorker(reg, callback) {
console.log('listenForWaitingServiceWorker')
function awaitStateChange() {
reg.installing.addEventListener('statechange', function() {
if (this.state === 'installed') callback(reg)
})
}
if (!reg) return
if (reg.waiting) return callback(reg)
if (reg.installing) awaitStateChange()
reg.addEventListener('updatefound', awaitStateChange)
}
// Register service worker
if (runServiceWorker && serviceWorkerAvailable) {
navigator.serviceWorker.register('/serviceWorker.js')
.then( (registration) => {
console.log('Service worker registered', registration)
listenForWaitingServiceWorker(registration, promptUserToRefresh) // <-- Added to existing code
})
}else{
console.log('Service worker disabled - process.env.NODE_ENV', process.env.NODE_ENV)
}
The problem with this code is that promptUserToRefresh() only gets called on initial service worker install, not when a new service worker is waiting!
Also, I get the below error when accepting the first install.
TypeError: registration.waiting is null
promptUserToRefresh app.js:154
awaitStateChange app.js:162
The error gets triggered in promptUserToRefresh(registration) by
registration.waiting.postMessage('skipWaiting')
I have also tested this approach with the same result: https://github.com/GoogleChrome/workbox/issues/1120
The code now works after simply re-arranging it!
Updated app.js
// *** PWA Functionality START ***
// skipWaiting() functions
function promptUserToRefresh(registration) {
// this is just an example - don't use window.confirm in real life; it's terrible
if (window.confirm("New version available! Refresh?")) {
registration.waiting.postMessage('skipWaiting')
}
}
function listenForWaitingServiceWorker(registration) {
console.log('listenForWaitingServiceWorker', registration)
function awaitStateChange() {
registration.installing.addEventListener('statechange', function() {
if (this.state === 'installed') promptUserToRefresh(registration)
})
}
if (!registration) return
if (registration.waiting) return promptUserToRefresh(registration)
if (registration.installing) awaitStateChange()
registration.addEventListener('updatefound', awaitStateChange)
}
//**
const enableServiceWorker = true
const serviceWorkerAvailable = ('serviceWorker' in navigator) ? true : false
// Register service worker
if (enableServiceWorker && serviceWorkerAvailable) {
navigator.serviceWorker.register('/serviceWorker.js')
.then( (registration) => {
console.log('Service worker registered', registration)
listenForWaitingServiceWorker(registration) // ** skipWaiting() code
})
}else{
console.log('Service worker disabled - process.env.NODE_ENV', process.env.NODE_ENV)
}
// Install prompt event handler
export let deferredPrompt
window.addEventListener('beforeinstallprompt', (event) => {
// Prevent Chrome 76 and later from showing the mini-infobar
event.preventDefault()
deferredPrompt = event // Stash the event so it can be triggered later.
try{
showInstallPromotion()
}catch(e){
console.error('showInstallPromotion()', e)
}
})
window.addEventListener('appinstalled', (event) => {
console.log('a2hs installed')
})
// *** PWA Functionality END *
Maybe the below (removed) lines caused all the trouble?
// reload once when the new Service Worker starts activating
let refreshing
navigator.serviceWorker.addEventListener('controllerchange', function() {
if (refreshing) return
refreshing = true
window.location.reload()
}
)
All that remains now is figuring out how not to show the prompt on first visit to the app / install! (^__^)/
Related
I'm writing my first react app with create-react-app but encountered a problem when setting up pwa.
I was trying to show a snackbar on new service-worker registration, but i won't get it working even with the template code.
Below is my service-worker.js under ./src/ (the same as the one in cra template)
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
} // If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
} // Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.
And here is my serviceWorkerRegistration.js under ./src/utils. Also nearly the same as the originally shipped one w/ location change and one console.log msg modification (I'm sure ive correctly imported it as the onSuccess callback was functional)
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available! Click the pop-up notification to update it!'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.');
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}
I'm registering sw with a useEffect hook:
useEffect(() => {
registerSW({
onSuccess: () => setShowSuccess(true),
onUpdate: sw => {
console.log('onUpdate triggered')
setShowReload(true)
setSW(sw)
},
});
}, []);
The onSuccess callback was working perfectly but the problem was, the onUpdate callback simply wont be triggered. Also the console.log msg in serviceWorkerRegistration itself('New content is available! Click the pop-up notification to update it!') won't appear.
My guess was that some browser apis have changed and the onupdatefound api will no longer work. But I'm not sure if it was because my app call sw to register on every page load, and at that time, the service worker registered is already the new sw.js so that there will not be an update.
I've checked my previous project and found that i was listening on navigator.serviceWorker's controllerchange. I'm not sure which method is correct and how can i implement this feature in react. And im curious about when the onUpdate callback is called and if i was using it correctly.
Thanks in advance!
After some console.log based debugging, i found that this bug was caused by sw registration module loaded after window.onload, causing the template code no longer useful as the registerValidSW function will only be triggered when window.onload target is reached.
I've modified the window.onload part to this to resolve this issue:
const callRegisterValidSW = () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service worker.'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
};
if (document.readyState === "complete") {
callRegisterValidSW();
} else {
window.addEventListener('load', callRegisterValidSW);
}
If someone here sees the maintainer of https://github.com/cra-template/pwa/, we may add some comment around this part in the shipped swRegistration function.
I'm trying to implement a basic service worker to assure that users of my simple web app have the latest code. So when I update html, js, or css files I can increment the cachename in the service worker file and prompt users to refresh, clear their cache, and get the latest code.
Until now I've relied on hacky ways to update javascript files (including a parameter in the referring URL: /javascript-file.js?v=1).
The with the service worker code below seem unpredictable: sometimes small changes to JS or CSS are reflected after I increment the cachename (code below). Sometimes the changes are reflected without incrementing the cachename, which suggests the code is ALWAYS pulling from the network (wasting resources).
How can you troubleshoot which version of files the code is using and whether the service worker is using cached or network versions? Am I not understanding the basic model for using service workers to achieve this goal?
Any help appreciated.
serv-worker.js (in root):
console.log('Start serv-worker.js');
const cacheName = '3.2121';
var urlsToCache = [
'home.html',
'home-js.js',
'web-bg.js',
'css/main.css',
'css/edit-menus.css'
];
self.addEventListener('install', event => {
console.log('Install event...', urlsToCache);
event.waitUntil(
caches.open(cacheName)
.then(function(cache) {
console.log('Opened cache', cacheName);
return cache.addAll(urlsToCache);
})
);
});
// Network first.
self.addEventListener('fetch', (event) => {
// Check the cache first
// If it's not found, send the request to the network
// event.respondWith(
// caches.match(event.request).then(function (response) {
// return response || fetch(event.request).then(function (response) {
// return response;
// });
// })
// );
event.respondWith(async function() {
try {
console.log('aPull from network...', event.request);
return await fetch(event.request);
} catch (err) {
console.log('aPull from cache...', event.request);
return caches.match(event.request);
}
}());
});
self.addEventListener('message', function (event) {
console.log('ServiceWorker cache version: ', cacheName, event);
console.log('Received msg1: ', event.data);
if (event.data.action === 'skipWaiting') {
console.log('ccClearing cache: ', cacheName);
// caches.delete('1.9rt1'); // hardcode old one
// caches.delete(cacheName); // actually removes cached versions
caches.keys().then(function(names) {
for (let name of names)
caches.delete(name);
});
self.skipWaiting();
}
});
Code in web-bg.js, which home.html references:
function servWorker(){
let newWorker;
function showUpdateBar() {
console.log('Show the update mssgg...ddddd');
$('#flexModalHeader').html('AP just got better!');
$('#flexModalMsg').html("<p>AP just got better. Learn about <a href='https://11trees.com/support/release-notes-annotate-pro-web-editor/'>what changed</a>.<br><br>Hit Continue to refresh.</p>");
$('#flexModalBtn').html("<span id='updateAPbtn'>Continue</span>");
$('#flexModal').modal('show');
}
// The click event on the pop up notification
$(document).on('click', '#updateAPbtn', function (e) {
console.log('Clicked btn to refresh...');
newWorker.postMessage({ action: 'skipWaiting' });
});
if ('serviceWorker' in navigator) {
console.log('ServiceWORKER 1234');
navigator.serviceWorker.register(baseDomain + 'serv-worker.js').then(reg => {
console.log('In serviceWorker check...', reg);
reg.addEventListener('updatefound', () => {
console.log('A wild service worker has appeared in reg.installing!');
newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
// Has network.state changed?
console.log('SSState is now: ', newWorker.state);
switch (newWorker.state) {
case 'installed':
if (navigator.serviceWorker.controller) {
// new update available
console.log('Detected service worker update...show update...');
showUpdateBar();
}
// No update available
break;
}
});
});
});
let refreshing;
navigator.serviceWorker.addEventListener('controllerchange', function (e) {
console.log('a1111xxxListen for controllerchange...', e);''
if (refreshing) return;
console.log('Refresh the page...');
window.location.reload();
refreshing = true;
});
} // End serviceworker registration logic
return;
} // END serv-worker
You've commented out the section for /// Check the cache first and then below that the try/catch statement again pulls from the network and falls back to the cache.
Uncomment this section of code and see if you're loading from the cache first.
// event.respondWith(
// caches.match(event.request).then(function (response) {
// return response || fetch(event.request).then(function (response) {
// return response;
// });
// })
// );
Don't forget that even if you request from the network from the service worker the browser will still use it's own internal cache to serve data. How long the data stays in the browser's cache depends on the expiration headers being sent by the server.
When using expires, it's still a fairly common solution to do something like:
index.html - expires after an hour. Has script/css tags that call out file names with ?v=x.y.z
/resources - folder that holds js and css. This folder has a very long expiration time. But that long expiration is short circuited by changing the ?v=x.y.z in index.html
I've used the above successfully in Progressive Web Apps (PWAs). But it is a little painful when debugging. The best option here is to manually clear out the cache and service worker from Dev Tools \ Application, if you're in Chrome.
I'm trying to implement a popup for the user that tells them that an update is available.
I'm using Create React App and it's default configuration.
I'm dispatching an action when it founds an update like so:
index.js
serviceWorker.register({
onSuccess: () => store.dispatch({ type: SW_UPDATE_FINISHED }),
onUpdate: reg => store.dispatch({ type: SW_UPDATE, payload: reg })
});
and I have a little component that should take care of showing the message and triggering the update:
const SwUpdate = () => {
const swUpdate = useSelector(state => state.app.swUpdate);
const swReg = useSelector(state => state.app.swReg);
const dispatch = useDispatch();
const updateSw = () => {
const swWaiting = swReg && swReg.waiting;
if (swWaiting) {
swWaiting.postMessage({ type: 'SKIP_WAITING' });
dispatch({ type: SW_UPDATE_FINISHED });
window.location.reload();
} else {
dispatch({ type: SW_UPDATE_FINISHED });
}
}
return (
<Snackbar
open={swUpdate}
color="primary"
message="New version available! 🎊"
action={
<Button color="primary" size="small" onClick={updateSw}>
UPDATE
</Button>
}
/>
)
}
export default SwUpdate;
It does what I would think it should do, reloads the page...
but...
Again... you have an update! and everytime a do it manually from the inspector is the same story, I get a new SW and I'm not getting why that happens
I cannot seem to find an example of this happening to another person and I'm worried is something that I'm doing wrong, or something wrong with the CRA service worker, I only added the callbacks to the register's config argument and manage it on the specific component.
Edited: Add the service-worker code
I have the serviceWorker.js AS IS from Create react App:
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets;
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit <LINK>'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See '
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}
I know that they use Workbox for the configuration, I'm testing all of this by running npm run build and serve -s build and reloading to try it out.
ALSO, here's the service-worker file generated by Workbox's configuration of Create react app when building the app:
/**
* Welcome to your Workbox-powered service worker!
*
* You'll need to register this file in your web app and you should
* disable HTTP caching for this file too.
*
* The rest of the code is auto-generated. Please don't update this file
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
*/
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
importScripts(
"/precache-manifest.a4724df64b745797c25a9173550ba2d3.js"
);
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
workbox.core.clientsClaim();
/**
* The workboxSW.precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
*/
self.__precacheManifest = [].concat(self.__precacheManifest || []);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});
workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("/index.html"), {
blacklist: [/^\/_/,/\/[^/?]+\.[^/]+$/],
});
Apparently, having another service-worker required by Firebase messaging for push notifications were triggering an update everytime.
I honestly didn't mentioned it in the original question because I thought it didn't have anything to do with my problem.
I ended up setting up FCM to use my service worker instead of having it own, and it all started to work properly :)
I can't say much more since I'm not 100% sure WHAT was the problem really, probably the FCM serviceworker wasn't updating itself and made the other one try to update itself everytime? serviceworkers are weird
I faced the exact same issue, whenever updating the pop up would show to update the page, and new service worker would be in waiting state. I believe the issue was coming from the checkbox Update on Reload (on Application tab) being ticked on. Once I turned it off, everything seemed to work as expected.
I have implemented Workbox to generate my service worker using webpack.
This works pretty well - I can confirm that revision is updated in the generated service worker when running yarn run generate-sw (package.json: "generate-sw": "workbox inject:manifest").
The problem is - I have noticed my clients are not updating the cache after a new release.
Even days after updating the service worker my clients are still caching the old code and new code will only cache after several refreshes and/or unregister the service worker.
For each release the const CACHE_DYNAMIC_NAME = 'dynamic-v1.1.0' is updated.
How can I ensure that clients updates the cache immediately after a new release?
serviceWorker-base.js
importScripts('workbox-sw.prod.v2.1.3.js')
const CACHE_DYNAMIC_NAME = 'dynamic-v1.1.0'
const workboxSW = new self.WorkboxSW()
// Cache then network for fonts
workboxSW.router.registerRoute(
/.*(?:googleapis)\.com.*$/,
workboxSW.strategies.staleWhileRevalidate({
cacheName: 'google-font',
cacheExpiration: {
maxEntries: 1,
maxAgeSeconds: 60 * 60 * 24 * 28
}
})
)
// Cache then network for css
workboxSW.router.registerRoute(
'/dist/main.css',
workboxSW.strategies.staleWhileRevalidate({
cacheName: 'css'
})
)
// Cache then network for avatars
workboxSW.router.registerRoute(
'/img/avatars/:avatar-image',
workboxSW.strategies.staleWhileRevalidate({
cacheName: 'images-avatars'
})
)
// Cache then network for images
workboxSW.router.registerRoute(
'/img/:image',
workboxSW.strategies.staleWhileRevalidate({
cacheName: 'images'
})
)
// Cache then network for icons
workboxSW.router.registerRoute(
'/img/icons/:image',
workboxSW.strategies.staleWhileRevalidate({
cacheName: 'images-icons'
})
)
// Fallback page for html files
workboxSW.router.registerRoute(
(routeData)=>{
// routeData.url
return (routeData.event.request.headers.get('accept').includes('text/html'))
},
(args) => {
return caches.match(args.event.request)
.then((response) => {
if (response) {
return response
}else{
return fetch(args.event.request)
.then((res) => {
return caches.open(CACHE_DYNAMIC_NAME)
.then((cache) => {
cache.put(args.event.request.url, res.clone())
return res
})
})
.catch((err) => {
return caches.match('/offline.html')
.then((res) => { return res })
})
}
})
}
)
workboxSW.precache([])
// Own vanilla service worker code
self.addEventListener('notificationclick', function (event){
let notification = event.notification
let action = event.action
console.log(notification)
if (action === 'confirm') {
console.log('Confirm was chosen')
notification.close()
} else {
const urlToOpen = new URL(notification.data.url, self.location.origin).href;
const promiseChain = clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((windowClients) => {
let matchingClient = null;
let matchingUrl = false;
for (let i=0; i < windowClients.length; i++){
const windowClient = windowClients[i];
if (windowClient.visibilityState === 'visible'){
matchingClient = windowClient;
matchingUrl = (windowClient.url === urlToOpen);
break;
}
}
if (matchingClient){
if(!matchingUrl){ matchingClient.navigate(urlToOpen); }
matchingClient.focus();
} else {
clients.openWindow(urlToOpen);
}
notification.close();
});
event.waitUntil(promiseChain);
}
})
self.addEventListener('notificationclose', (event) => {
// Great place to send back statistical data to figure out why user did not interact
console.log('Notification was closed', event)
})
self.addEventListener('push', function (event){
console.log('Push Notification received', event)
// Default values
const defaultData = {title: 'New!', content: 'Something new happened!', openUrl: '/'}
const data = (event.data) ? JSON.parse(event.data.text()) : defaultData
var options = {
body: data.content,
icon: '/images/icons/manifest-icon-512.png',
badge: '/images/icons/badge128.png',
data: {
url: data.openUrl
}
}
console.log('options', options)
event.waitUntil(
self.registration.showNotification(data.title, options)
)
})
Should I delete the cache manually or should Workbox do that for me?
caches.keys().then(cacheNames => {
cacheNames.forEach(cacheName => {
caches.delete(cacheName);
});
});
Kind regards /K
I think your problem is related to the fact that when you make an update to the app and deploy, new service worker gets installed, but not activated. Which explains the behaviour why this is happening.
The reason for this is registerRoute function also registers fetch listeners , but those fetch listeners won't be called until new service worker kicks in as activated. Also, the answer to your question: No, you don't need to remove the cache by yourself. Workbox takes care of those.
Let me know more details. When you deploy new code, and if users close all the tabs of your website and open a new one after that, does it start working after 2 refreshes? If so , that's how it should be working. I will update my answer after you provide more details.
I'd suggest you read the following: https://redfin.engineering/how-to-fix-the-refresh-button-when-using-service-workers-a8e27af6df68 and follow the 3rd approach.
One way to get WorkBox to update when you have the files locally, not on a CDN, is the following way:
In your serviceworker.js file add an event listener so that WorkBox skips waiting when there is an update, my code looks like this:
importScripts('Scripts/workbox/workbox-sw.js');
if (workbox) {
console.log('Workbox is loaded :)');
// Add a message listener to the waiting service worker
// instructing it to skip waiting on when updates are done.
addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
skipWaiting();
}
});
// Since I am using Local Workbox Files Instead of CDN I need to set the modulePathPrefix as follows
workbox.setConfig({ modulePathPrefix: 'Scripts/workbox/' });
// other workbox settings ...
}
In your client side page add an event listener for loads if service worker is in the navigator. As a note I am doing this in MVC so I put my code in the _Layout.cshtml so that it can update from any page on my website.
<script type="text/javascript">
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker
// register WorkBox, our ServiceWorker.
.register("<PATH_TO_YOUR_SERVICE_WORKER/serviceworker.js"), { scope: '/<SOME_SCOPE>/' })
.then(function (registration) {
/**
* Whether WorkBox cached files are being updated.
* #type {boolean}
* */
let updating;
// Function handler for the ServiceWorker updates.
registration.onupdatefound = () => {
const serviceWorker = registration.installing;
if (serviceWorker == null) { // service worker is not available return.
return;
}
// Listen to the browser's service worker state changes
serviceWorker.onstatechange = () => {
// IF ServiceWorker has been installed
// AND we have a controller, meaning that the old chached files got deleted and new files cached
// AND ServiceWorkerRegistration is waiting
// THEN let ServieWorker know that it can skip waiting.
if (serviceWorker.state === 'installed' && navigator.serviceWorker.controller && registration && registration.waiting) {
updating = true;
// In my "~/serviceworker.js" file there is an event listener that got added to listen to the post message.
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
// IF we had an update of the cache files and we are done activating the ServiceWorker service
// THEN let the user know that we updated the files and we are reloading the website.
if (updating && serviceWorker.state === 'activated') {
// I am using an alert as an example, in my code I use a custom dialog that has an overlay so that the user can't do anything besides clicking okay.
alert('The cached files have been updated, the browser will re-load.');
window.location.reload();
}
};
};
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}).catch(function (err) {
//registration failed :(
console.log('ServiceWorker registration failed: ', err);
});
});
} else {
console.log('No service-worker on this browser');
}
</script>
Note: I used the browser's service worker to update my WorkBox cached files, also, I've only tested this in Chrome, I have not tried it in other browsers.
What am I trying to do?
I am creating a progressive web application (PWA) and in order to send upgrade the app correctly, I am working on a step where the user is notified and once the user says "upgrade", the serviceWorker calls skipWaiting()
What have I done so far?
I am following up a nice article https://redfin.engineering/how-to-fix-the-refresh-button-when-using-service-workers-a8e27af6df68 to achieve this
In order to remove the complexity, I am only testing sending of messages between serviceWorkers to see how skipWaiting works. I am using create-react-app (v"react": "^16.5.2",) which comes with workbox bundled as plugin.
My current registerServiceWorker.js looks like
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (!isLocalhost) {
// Is not local host. Just register service worker
registerValidSW(swUrl);
} else {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
}
});
window.addEventListener('message', messageEvent => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (messageEvent.data === 'skipWaiting') {
console.log('skipWaiting');
return navigator.serviceWorker.getRegistration(swUrl)
.then(registration => registration.skipWaiting());
}
console.log(`message=${messageEvent.data}`);
});
let refreshingPage;
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('refreshing page now');
if (refreshingPage) return;
refreshingPage = true;
window.location.reload();
});
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('>> New content is available; please refresh.');
navigator.serviceWorker.controller.postMessage('skipWaiting');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}
As you see, when a new serviceWorker is installed, I am sending a postMessage as
console.log('>> New content is available; please refresh.');
navigator.serviceWorker.controller.postMessage('skipWaiting');
and I am expecting the message to be handled with the function
window.addEventListener('message', messageEvent => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (messageEvent.data === 'skipWaiting') {
console.log('skipWaiting');
return navigator.serviceWorker.getRegistration(swUrl)
.then(registration => registration.skipWaiting());
}
console.log(`message=${messageEvent.data}`);
});
Then, I deploy my changes so that this serviceWorker is ready.
Then, I make changes to my application (index.html) and now when I deploy, I see multiple messages being logged, but none with skipWaiting
message=
registerServiceWorker.js:53 message=!_{"h":"I0_1548194465894"}
registerServiceWorker.js:53 message=!_{"s":"/I0_1548194465894::_g_rpcReady","f":"I0_1548194465894","r":"I0_1548194465894","t":"32296067","c":1,"a":[null],"g":false}
registerServiceWorker.js:53 message=!_{"s":"__cb","f":"I0_1548194465894","r":"I0_1548194465894","t":"32296067","c":null,"a":[1,[null]],"g":false}
registerServiceWorker.js:53 message=!_{"s":"__cb","f":"I0_1548194465894","r":"I0_1548194465894","t":"32296067","c":null,"a":[2,[null]],"g":false}
registerServiceWorker.js:53 message=!_{"s":"/I0_1548194465894::_g_restyleMe","f":"I0_1548194465894","r":"I0_1548194465894","t":"32296067","c":2,"a":[{"setHideOnLeave":false}],"g":false}
registerServiceWorker.js:53 message=!_{"s":"__cb","f":"I0_1548194465894","r":"I0_1548194465894","t":"32296067","c":null,"a":[4,[null]],"g":false}
registerServiceWorker.js:53 message=!_{"s":"/I0_1548194465894::authEvent","f":"I0_1548194465894","r":"I0_1548194465894","t":"32296067","c":3,"a":[{"type":"authEvent","authEvent":{"type":"unknown","eventId":null,"urlResponse":null,"sessionId":null,"postBody":null,"tenantId":null,"error":{"code":"auth/no-auth-event","message":"An internal error has occurred."}}}],"g":false}
registerServiceWorker.js:53 message=!_{"s":"__cb","f":"I0_1548194465894","r":"I0_1548194465894","t":"32296067","c":null,"a":[3,[true]],"g":false}
What am I doing wrong here?
There a few things wrong
navigator.serviceWorker.controller.postMessage('skipWaiting');
This is sending a message from window to worker, but you're waiting for the message on the window
controller -> is not the worker instance. postMessage() would send the message to that worker.
You don't want to post to that worker though - it's your current active worker, while you want to notify the newly installed worker that haven't taken over yet
Even if you manage to run the code in the message event handler it won't do
if (messageEvent.data === 'skipWaiting') {
console.log('skipWaiting');
return navigator.serviceWorker.getRegistration(swUrl)
.then(registration => registration.skipWaiting());
}
There's no registration.skipWaiting() method. We call skipWaiting inside the service-worker.js code
In your case you would add an event listener for message inside the worker code and intercept the "Skip Waiting" message there
worker code
self.addEventListener('message', (event) => {
if (event.data == 'skipWaiting') self.skipWaiting();
});
window code (changes only)
Send a message to the now installed worker to skip waiting and take over
console.log('>> New content is available; please refresh.');
installingWorker.postMessage('skipWaiting');