/pwa-helpers

Utilities for common patterns that help you build your Progressive Web App

Primary LanguageJavaScriptMIT LicenseMIT

Pwa Helper Components

This webcomponent follows open-wc recommendations.

These are some utilities for common patterns that help you build your Progressive Web App (PWA). Not to be confused with @polymer/pwa-helpers.

If you're new to building Progressive Web Apps, we recommend you read the Offline Cookbook by Jake Archibald, or take the free Udacity course at Offline Web Applications.

Other useful resources for developing Progressive Web Apps are:

  • The Mental Gymnastics of Service Worker - A set of visualisations that guide you through the concepts of service worker step by step.
  • Workbox - Workbox is a set of libraries and Node modules that make it easy to cache assets and take full advantage of features used to build Progressive Web Apps.
  • serviceworke.rs - Common Service Worker patterns
  • Service Workies - Learn Service Workers inside and out with this game of Service Worker mastery
  • PWA Builder Feature Store - Common recipes for building PWA's

Installation

Installation:

npm i --save pwa-helper-components

Importing like this will self register the web component:

import 'pwa-helper-components/pwa-install-button.js';
import 'pwa-helper-components/pwa-dark-mode.js';
import 'pwa-helper-components/pwa-update-available.js';

If you want more control over the registration of the component, you can import the class and handle registration yourself:

import { PwaInstallButton, PwaUpdateAvailable, PwaDarkMode } from 'pwa-helper-components';

customElements.define('my-install-button', PwaInstallButton);
customElements.define('my-update-available', PwaUpdateAvailable);
customElements.define('my-dark-mode', PwaDarkMode);

Or via unpkg:

import 'https://unpkg.com/pwa-helper-components/pwa-install-button.js';
import 'https://unpkg.com/pwa-helper-components/pwa-update-available.js';
import 'https://unpkg.com/pwa-helper-components/pwa-dark-mode.js';

// or:

import { PwaInstallButton, PwaUpdateAvailable, PwaDarkMode } from 'https://unpkg.com/pwa-helper-components/index.js';

<pwa-install-button>

<pwa-install-button> is a zero dependency web component that lets users easily add a install button to their PWA.

You can find a live demo here. (Note: it may take a few seconds before the buttons become visible, because the beforeinstallprompt may not have fired yet)

<pwa-install-button> will have a hidden attribute until the beforeinstallprompt event is fired. It will hold on to the event, so the user can click the button whenever they are ready to install your app. It will also hold on to the event even if the user has declined the initial prompt. If they decline to install your app, and leave your page it may take some time before the browser sends another beforeinstallprompt again. See the FAQ for more information.

Usage

You can provide your own button as a child of the <pwa-install-button>, or use the default (white-label) fallback button.

<!-- Will use a slotted default fallback button -->
<pwa-install-button>
</pwa-install-button>
<!-- Will use the provided button element -->
<pwa-install-button>
    <button>Install!</button>
</pwa-install-button>

You can also use a Web Component:

<pwa-install-button>
    <mwc-button>Install!</mwc-button>
</pwa-install-button>

Instead of only showing a button, you can also make a custom app listing experience, as long as it contains a button:

<pwa-install-button>
  <img src="./app-logo.png"/>
  <h1>MyApp</h1>
  <h2>Key features:</h2>
  <ul>
    <li>Fast</li>
    <li>Reliable</li>
    <li>Offline first</li>
  </ul>
  <h2>Description:</h2>
  <p>MyApp is an awesome Progressive Web App!</p>
  <button>Install!</button>
</pwa-install-button>

Do note that you may want to defer the <pwa-install-button> becoming visible if you choose a pattern like this, as it may be obstructive to your user. You can do this by overriding the default [hidden] styling, and listening for the pwa-installable event.

Events

<pwa-install-button> will fire a pwa-intallable event when it becomes installable, and a pwa-installed event when the user has installed your PWA. If the user has dismissed the prompt, a pwa-installed event will be fired with a false value.

You can listen to these events like this:

const pwaInstallButton = document.querySelector('pwa-install-button');

// The app is installable
pwaInstallButton.addEventListener('pwa-installable', (event) => {
  console.log(event.detail); // true
});

// User accepted the prompt
pwaInstallButton.addEventListener('pwa-installed', (event) => {
  console.log(event.detail); // true
  // You may want to use this event to send some data to your analytics
});

// If the user dismisses the prompt
pwaInstallButton.addEventListener('pwa-installed', (event) => {
  console.log(event.detail); // false
});

Requirements

Make sure your PWA meets the installable criteria, which you can find here. You can find a tool to generate your manifest.json here.

<pwa-update-available>

🚨 This web component may require a small addition to your service worker if you're not using workbox 🚨

<pwa-update-available> is a zero dependency web component that lets users easily show a 'update available' notification.

<pwa-update-available> will have a hidden attribute until the updatefound notification is sent, and the new service worker is succesfully installed.

Clicking the <pwa-update-available> component will post a message to your service worker with a {type: 'SKIP_WAITING'} object, which lets your new service worker call skipWaiting and then reload the page on controllerchange.

Instructions on how to catch this message in your service worker are described down below.

Usage:

<!-- Will use the default slot fallback button -->
<pwa-update-available>
</pwa-update-available>
<!-- Will use the provided button element -->
<pwa-update-available>
  <button>A new update is available! Click here to update.</button>
</pwa-update-available>

The next thing to do is update your service worker to listen for the message event. To add this snippet of code to your service worker, you can do the following:

Using Workbox

If you're using workbox, no changes are required, as workbox automatically includes the necessary code in your generated service worker.

Manual approach

If you're manually writing your service worker, you can simply copy the code snippet down below anywhere in the global scope of your service worker.

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

skipWaiting will refresh any open tabs/clients.

Prior art by:

Events

<pwa-update-available> will fire a pwa-update-available event when a update is available.

You can listen to this event like this:

const pwaUpdateAvailable = document.querySelector('pwa-update-available');

pwaUpdateAvailable.addEventListener('pwa-update-available', (event) => {
  console.log(event.detail); // true
});

If you're interested in reading more about this subject, you can check out this blog: How to Fix the Refresh Button When Using Service Workers.

addPwaUpdateListener

Executes a callback whenever a new update is available.

If you're using the <pwa-update-available> component, it can happen that you're dynamically rendering the component, and have no way to listen to the pwa-update-available event, because your component is not actually in the DOM yet. But sometimes you may want to show a subtle indicator that an update is available, and need some way to find out that an update actually is available.

Usage

Here's an example:

addPwaUpdateListener((updateAvailable) => {
  /* Using a web component: */
  this.updateAvailable = updateAvailable;

  /* Using (P)react: */
  this.setState({
    updateAvailable
  })
});

<pwa-dark-mode>

<pwa-update-available> is a zero dependency web component that lets users toggle a 'dark' class on the html element, and effectively toggle darkmode on and off. It will also persist the preference in local storage. This web component should be used in combination with installDarkModeHandler.

When used in combination with installDarkModeHandler, you can very easily implement darkmode in your PWA. Just call the installDarkModeHandler whenever the page loads to respect either the systems darkmode preference, or if a visitor has already manually set a preference; use that instead, and use the <pwa-dark-mode> anywhere in your app to toggle the darkmode state.

Usage

You can provide your own button as a child of the <pwa-dark-mode>, or use the default (white-label) fallback button.

<!-- Will use a slotted default fallback button -->
<pwa-dark-mode>
</pwa-dark-mode>
<!-- Will use the provided button element -->
<pwa-dark-mode>
  <button>Toggle dark mode!</button>
</pwa-dark-mode>

installDarkModeHandler

Installs a mediaQueryWatcher that listens for (prefers-color-scheme: dark), and toggles a dark mode class if appropriate. This means that on initial pageload:

  • If the user hasn't manually set a dark mode preference yet, it will respect the systems preference
  • If the user has set a preference, it will always respect that preference, because the user has manually opted into it.

Dark mode preference is persisted in localstorage. Use with <pwa-dark-mode> to easily add a button that lets the user toggle between light and dark mode.

Usage

Simply import the handler, and call it on pageload (preferably early). The handler should only be installed once.

Basic

import { installDarkModeHandler } from 'pwa-helper-components';

// Basic usage:
installDarkModeHandler();

Now all you have to do is write some css:

:root {
  --my-text-col: black;
  --my-bg-col: white;
}

.dark {
  --my-text-col: white;
  --my-bg-col: black;
}

body {
  background-color: var(--my-bg-col);
  color: var(--my-text-col);
}

Advanced

You can also add a callback to execute whenever darkmode is changed to do extra work, like changing favicons.

./utils/setFavicons.js:

export function setFavicons(darkMode) {
  const [iconBig, iconSmall] = [...document.querySelectorAll("link[rel='icon']")];
  const manifest = document.querySelector("link[rel='manifest']");
  const theme_color = document.querySelector("meta[name='theme-color']");

  if (darkMode) {
    manifest.href = '/manifest-dark.json';
    iconBig.href = 'src/assets/favicon-32x32-dark.png';
    iconSmall.href = 'src/assets/favicon-16x16-dark.png';
    theme_color.setAttribute('content', '#303136');
  } else {
    manifest.href = '/manifest.json';
    iconBig.href = 'src/assets/favicon-32x32.png';
    iconSmall.href = 'src/assets/favicon-16x16.png';
    theme_color.setAttribute('content', '#ffffff');
  }

  document.getElementsByTagName('head')[0].appendChild(manifest);
  document.getElementsByTagName('head')[0].appendChild(iconBig);
  document.getElementsByTagName('head')[0].appendChild(iconSmall);
  document.getElementsByTagName('head')[0].appendChild(theme_color);
}

./main.js:

import { setFavicons } from './utils/setFavicons.js';

installDarkModeHandler((darkMode) => {
  setFavicons(darkMode)
});

Note that if you do this, you should also extend the PwaDarkMode web component and add a callback method, to make sure these changes also get applied if a user manually changes the preference, rather than only changing the systems preference:

import { PwaDarkMode } from 'pwa-helper-components/pwa-dark-mode/PwaDarkMode.js';
import { setFavicons } from './utils/setFavicons.js';

class MyDarkMode extends PwaDarkMode {
  callback(darkMode) {
    setFavIcons(darkMode);
  }
}

customElements.define('my-dark-mode', MyDarkMode);

Summarize

The installDarkModeHandler will fire on pageload: if a user has not manually set a preference, it will fire when the user changes their system preference, if a user has manually set a preference, it will always respect that preference.

FAQ

Why is my install button not showing up on subsequent visits?

The BeforeInstallPromptEvent may not immediately be fired if the user has initially declined the prompt. This is intentional behavior left that way to avoid web pages annoying the users to repeatedly prompt the user for adding to home screen. <pwa-install-button> will hold on to the event, even if the user declined to install the app; if they change their mind, they will still be able to click the button. The button may not be immediately visible on subsequent visits though; this is intended browser behavior.

Different browsers may use a different heuristic to fire subsequent BeforeInstallPromptEvents.

skipWaiting doesn't work!

Your service worker may not call skipWaiting if there are tasks that are still running, like for example Event Sources, which are used by, for example, the --watch mode of es-dev-server in order to reload the page on file changes.

If you want to test your service worker with your production build, you can remove the --watch flag from your es-dev-server script, or you can run a simple http-server with npx http-server on your /dist folder to make sure everything works as expected.

Single Page Apps

When developing single page applications, make sure to have a <base href="/"> element in your index.html, and return your index.html in your service worker.

Using Workbox

If you're using Workbox, you can register a navigation route like so:

workbox.routing.registerNavigationRoute(
  // Assuming '/single-page-app.html' has been precached,
  // look up its corresponding cache key.
  workbox.precaching.getCacheKeyForURL('/single-page-app.html')
);

You can read more about this approach here.

Manual approach

If you're not using Workbox, you can use the following code snippet in your service worker's fetch handler:

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(caches.match('/'));
    return;
  }
});