On PWA Update Patterns
The year is 2020. Service workers are still rocket science. Humanity still hasn't nailed service worker updates. The search continues.
TL;DR: Skip to the code.
Nobody's got time for that, skip waiting!
Service worker updates is a subject that is still not well explored yet. Many websites will, much to many users chagrin, reload whenever a new service worker takes control of the page. This pattern is commonly known as the 'skipWaiting pattern', and is excellenty described in this blog post by Dean Hume. It's also very often referred to on your favourite developer forum.
The skipWaiting pattern is fine for most content sites like blogs, or documentation sites, but it can also be frustrating when done wrong;
Even if you have minimal state, like a menu that has expandable items, it could happen that a user visits your page, expands a menu item, and BOOM the new service worker takes over, skips waiting, refreshes the page and... The menu item is closed again. Not fun for the user.
Fortunately, we can work around this using the sessionStorage API. Here's how we approached this on open-wc.org:
if (sessionStorage.getItem('openItems')) {
const openItems = JSON.parse(sessionStorage.getItem('openItems'));
const menuItems = [...document.querySelectorAll('section > p.sidebar-heading')];
if (openItems.length === menuItems.length) {
menuItems.forEach((item, i) => {
if (openItems[i]) {
item.click();
}
});
sessionStorage.removeItem('openItems');
}
}
let refreshing;
navigator.serviceWorker.addEventListener('controllerchange',
function() {
if (refreshing) return;
refreshing = true;
const openItems = [...document.querySelectorAll('section > p.sidebar-heading')]
.map(item => item.classList.contains('open'));
sessionStorage.setItem('openItems', JSON.stringify(openItems));
window.location.reload();
}
);
Let's go through this step-by-step:
- When a new service worker is available, the
controllerchange
event is fired - But before we reload the page, we check to see if the user has interacted with the menu, and we save it to session storage
- We then reload the page
- On pageload, we check to see if there are any entries in sessionStorage, and if there is; we restore the state.
You might be wondering why we use sessionStorage for this way of doing things, and not localStorage for example. The reason for this is that sessionStorage data is related only to your current session, or tab, and the data will expire as soon as the session is closed. In this case, it makes more sense to use sessionStorage, because the user will likely only have interacted with the current tab before the new service worker takes over.
Now, when a user has had already interacted with the menu before the service worker update, the page reload will still happen, but it will restore the users state, making the reload hardly noticable, and making the user's experience fun instead of frustrating.
But there's more...
We recently ran into an additional quirk with this pattern. When doing some updates to our website, we noticed that our lighthouse score bugged out on the 'Best Practices' section:
To understand why this happens, we have to take a close look at the client side code for the skipWaiting pattern:
let refreshing;
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (refreshing) return;
window.location.reload();
refreshing = true;
});
What happens here is that any time the controllerchange
event fires, the page will do a reload. This is great when you already have a service worker, and want the new service worker to take over; but it also means that the page will reload when the user has visited for the first time, and consequently cause lighthouse to bug out.
Consider the following scenario:
- A user visits your page for the first time, there is no active and controlling service worker yet
- The service worker will start installing and precaching all your assets
- Since there is no current service worker, the service worker activates, and fires the
controllerchange
event - The page now does a full reload, that's barely noticeable because all the assets are cached
Granted, the fact that lighthouse errors out on this seems to be a bug on the part of Lighthouse, but it also seems a bit odd to do a reload right after the user first visits your page.
Here's how we fixed this problem:
async function handleUpdate() {
if ("serviceWorker"in navigator) {
let refreshing;
// check to see if there is a current active service worker
const oldSw = (await navigator.serviceWorker.getRegistration())?.active?.state;
navigator.serviceWorker.addEventListener('controllerchange', async () => {
if (refreshing) return;
// when the controllerchange event has fired, we get the new service worker
const newSw = (await navigator.serviceWorker.getRegistration())?.active?.state;
// if there was already an old activated service worker, and a new activating service worker, do the reload
if(oldSw === 'activated' && newSw === 'activating') {
refreshing = true;
window.location.reload();
}
});
}
}
handleUpdate();
Now our lighthouse scores are as we expect them:
And we got rid of the unnecessary reload whenever a user first visits our page.
An update is available!
Another popular pattern is to show a toast whenever an update is available, as seen down below.
This pattern is less than desired for several reasons, the first one being... Nobody seems to really like toasts, developers and users alike. I mean, who really likes popups jumping up when browsing the web? Can't we do better? Additionally, toasts are hard to get right in terms of accessibility as well.
Perhaps a more subtle, user friendly implementation of the "update available pattern", as explained by Jad Joubran and his talk Secrets of Native-like PWA's (timestamp: 25:49), is to show a very subtle indicator that an update is available. This is less likely to catch a user off-guard, and requires no flashy pop-ups yelling at a user's face.
pwa-helper-components
ships a addPwaUpdateListener
helper function that executes a callback when an update is available, so you can then easily display a subtle indicator to the user.
I went into more detail on this pattern in my previous blog post, if you're interested in reading more about this.
But the second problem of the "update available" pattern might be even more important; The update is a black box. The user has no idea what they're signing up for when the click on the update button. For all they know, the entire web app could be completely different once they've clicked the update button. Do you like huge, unexpected changes? I know I don't!
Don't you think this is kind of backwards, too? Shouldn't we give the user a good experience, and let them know up front what they're signing up for?
Changelog update pattern
Instead, why dont we offer full transparancy to our users, and simply show them the changelog? That way, the user will know exactly what kind of updates they're opting into. This pattern is slightly more involved than including a 6 LoC snippet on your page, but we'll walk through it step by step.
Want to jump straight to the code? I dont blame you. You can find the repo on github.
Step 1:
Lets start by generating a new project with open-wc:
npm init @open-wc
Step 2:
In order for this pattern to work, we'll somehow need to maintain the current version number of our PWA on the client side, so the first thing we'll do is create a version.js
module in our src/
folder:
./src/version.js
:
export default 'dev';
At build time, we're going to extract the version
property from our package.json
, and rewrite 'dev'
to that version number. We'll also want to precache this module, but we'll get to that in a minute.
Step 3:
Let's implement the logic to rewrite 'dev'
in our version.js
to the current version number from our package.json
:
Import the package.json
in your rollup.config.js
, and get the path to our version.js
module:
rollup.config.js
:
import packageJson from './package.json';
const versionModulePath = require.resolve('./src/version.js');
Now, in the plugins
array of our rollup.config.js
, we'll add a little bit of logic to rewrite the version number:
{
name: 'rewrite-version-number',
load(id) {
// replace the version module with a live version from the package.json
if (id === versionModulePath) {
return `export default '${packageJson.version}'`;
}
},
},
Whenever rollup will load our version.js
file, we simply rewrite the contents of the file to export default '${packageJson.version}'
.
Step 4:
Next up, we're going to take our CHANGELOG.md
, and turn it into a JSON file, so we can easily consume it and compare changes on the frontend.
To do this, we'll need to install a dev dependency:
npm i -D md-2-json
And import it in our rollup.config.js
:
import md2json from 'md-2-json';
Again, in your rollup.config.js
's plugins
array, add the following code:
{
name: 'generate-changelog-json',
writeBundle() {
const changelog = fs.readFileSync('./CHANGELOG.md', 'utf8');
fs.writeFileSync(
'./dist/CHANGELOG.json',
JSON.stringify(md2json.parse(changelog))
);
},
},
This will turn out CHANGELOG.md
into a CHANGELOG.json
, and looks a little bit like:
{"Changelog":{"1.0.2":{"raw":"- Added the thing\n\n"},"1.0.1":{"raw":"- Fixed bug in keyboard control for dark mode toggle\n- Responsive styling fix\n\n"},"1.0.0":{"raw":"- Initial release\n\n\n"}}}
Step 5:
Great, so far we've found a way to maintain the current version number of our PWA, and turn our CHANGELOG.md
into something that we can easily consume on the frontend.
Now, we'll need to make some changes to our workbox
configuration in our rollup.config.js
.
What we want to do is: set skipWaiting
and clientsClaim
to false
, and add CHANGELOG.json
to our globIgnores
. The reason we add the CHANGELOG.json
to globIgnores
is because we want to always be able to get the latest changes fresh from the network. We'll also set the injectServiceWorker
option to true
. This will automagically add the service worker registration code to your index.html
.
const baseConfig = createSpaConfig({
// ...
workbox: {
globIgnores: ['./CHANGELOG.json'],
skipWaiting: false,
clientsClaim: false,
},
injectServiceWorker: true,
// ...
});
Step 6:
Great! We're done with our build-time changes, so lets implemented the frontend. We're going to install three (very tiny) dependencies for this:
npm i -S pwa-helper-components @thepassle/generic-components es-semver
-
pwa-helper-components: will give us a
addPwaUpdateListener
function, that fires a callback whenever a new service worker is available - generic-components: will give us an accessible dialog component to show our updates in
- es-semver: will give us an ES module version to compare the current apps version number to the entries in the changelog
Let's start by adding the addPwaUpdateListener
, in your src/
folder, find your app component and add the following code:
import { LitElement, html, css } from 'lit-element';
import { openWcLogo } from './open-wc-logo.js';
import { addPwaUpdateListener } from 'pwa-helper-components';
export class ChangelogUpdatePattern extends LitElement {
static get properties() {
return {
updateAvailable: { type: Boolean },
};
}
constructor() {
super();
this.updateAvailable = false;
}
connectedCallback() {
super.connectedCallback();
addPwaUpdateListener((updateAvailable) => {
this.updateAvailable = updateAvailable;
});
}
static get styles() {/** omitted to brevity */}
render() {
return html`
<main>
<div class="logo">${openWcLogo}</div>
<h1>My app</h1>
<p>Welcome to my app!</p>
</main>
<p class="app-footer">
🚽 Made with love by
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/open-wc"
>open-wc</a
>.
</p>
`;
}
}
As you can see we imported the addPwaUpdateListener
, and registered it in the connectedCallback
.
Step 7:
Great, we'll now be notified whenever a new service worker is available, but we're not really reacting to it yet; time to implement the dialog!
We'll import the dialog from @thepassle/generic-components
, add some conditional rendering in our render
method, and add a openDialog
method to our class. Here's what our code looks like at this point in time:
import { LitElement, html, css } from 'lit-element';
import { render } from 'lit-html';
import { addPwaUpdateListener } from 'pwa-helper-components';
import { dialog } from '@thepassle/generic-components/generic-dialog/dialog.js';
import { openWcLogo } from './open-wc-logo.js';
import './update-dialog.js';
export class ChangelogUpdatePattern extends LitElement {
static get properties() {
return {
updateAvailable: { type: Boolean },
};
}
constructor() {
super();
this.updateAvailable = false;
}
connectedCallback() {
super.connectedCallback();
addPwaUpdateListener((updateAvailable) => {
this.updateAvailable = updateAvailable;
});
}
static get styles() {/** */}
openDialog(e) {
dialog.open({
invokerNode: e.target,
content: dialogNode => {
dialogNode.id = 'dialog';
render(html`<update-dialog></update-dialog>`, dialogNode);
},
});
}
render() {
return html`
<main>
${this.updateAvailable
? html`
<button @click=${this.openDialog} class="update button">
Hey!
</button>`
: ''
}
<div class="logo">${openWcLogo}</div>
<h1>My app</h1>
<p>Welcome to my app!</p>
</main>
<p class="app-footer">
🚽 Made with love by
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/open-wc"
>open-wc</a
>.
</p>
`;
}
}
Step 8:
Still with us? Excellent, you're doing great. Slightly more involved than adding a 6 LoC snippet in your index.html
, huh? Welp, it's 2020, and service workers are still rocket science.
Now it's time to create a new update-dialog.js
file in your src/
folder. This update-dialog
component will contain the logic and UI to do the diffing between version numbers.
We'll first create 2 utility functions: getChanged
and skipWaiting
-
getChanged
: will fetch theCHANGELOG.json
fresh from the network, and compile any new updates for us in an array -
skipWaiting
: will get a reference to the new waiting service worker, and tell it to skip waiting.
async function getChanged(version) {
const { Changelog } = await (await fetch('./CHANGELOG.json')).json();
return Object.keys(Changelog)
.filter(item => satisfies(item, `>${version}`))
.map(
item => html`
<li>
<h2>${item}</h2>
<div class="changelog">${Changelog[item].raw}</div>
</li>
`
);
}
async function skipWaiting() {
const reg = await navigator.serviceWorker.getRegistration();
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}
And finally, here's what our update-dialog
component looks like:
import { html, LitElement } from 'lit-element';
import { dialog } from '@thepassle/generic-components/generic-dialog/dialog.js';
import version from './version.js';
class UpdateDialog extends LitElement {
static get properties() {
return {
changed: { type: Array },
};
}
constructor() {
super();
this.changed = [];
}
createRenderRoot() {
return this;
}
async connectedCallback() {
super.connectedCallback();
this.changed = await getChanged(version);
}
render() {
return html`
<button @click=${() => dialog.close()} class="close button">
x
</button>
<h1>There's an update available!</h1>
<p>Here's what's changed:</p>
<ul>
${this.changed}
</ul>
<div class="dialog-buttons">
<button class="button" @click=${skipWaiting}>Install update</button>
<button class="button" @click=${() => dialog.close()}>Close</button>
</div>
`;
}
}
customElements.define('update-dialog', UpdateDialog);
Step 9 (optional):
The downside of this pattern is that, well, it's not very straightforward to implement, and for every change you make, you'll need to adjust your CHANGELOG.md
and package.json
accordingly.
In hopes to make this a bit easier for you, I created a Github Action that for each pull request will check if you've changed your CHANGELOG.md
and package.json
. You can use it by creating a .github/workflows/
directory, and adding the following workflow:
package-json-changelog-enforcer.yml
:
name: package-json-changelog-enforcer
on: [pull_request]
jobs:
package-json-changelog-enforcer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: '0'
- uses: thepassle/package-json-enforcer@0.0.10
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
Conclusion
And that's it. If you followed along step-by-step, you're a champ. If you didn't, and skipped through to the Github repo; again, I don't blame you.
I'd like to conclude this blogpost the same way I started it: The year is 2020. Service workers are still rocket science. Humanity still hasn't nailed service worker updates. The search continues.
Top comments (6)
You just gave me the idea to trigger SW updates only when we change a PWA-specific change log file (currently I'm using the git hash and it'll trigger an update for changes 100% unrelated to what's cached by the service worker...)
Great write-up!
Thanks Jeff!
Seconded. This clarifies a lot for me.
Thanks man, happy to hear that 🙂
Awesome article! I can confirm service workers are a pain to work with, I have three years of headaches to back me up. Thanks for this approachable explanation, I've got something to try out today!