DEV Community

Cover image for A deep-dive on a Progressive Web App implementation for a React-based App Platform (DHIS2)
Kai Vandivier
Kai Vandivier

Posted on • Originally published at developers.dhis2.org

A deep-dive on a Progressive Web App implementation for a React-based App Platform (DHIS2)

At DHIS2, we're a remote-first team of developers building the world's largest health information management system. DHIS2 is a free and open-source global public good developed at the University of Oslo. It is used in more than 90 countries around the world, serving as the national health information system for more than 70 countries. It is a general-purpose data collection and analytics platform used to manage routine health service delivery as well as interventions targeting COVID-19, Malaria, HIV/AIDS, Tuberculosis, maternal and child health, and more. Our tech stack includes a postgres database, a Java server usually deployed on-premise, a native Android app, and more than 30 React-based web applications. To support the many web applications maintained by our team as well as those developed by a growing community of developers around the world, we provide a suite of build tools and common application infrastructure we call the App Platform.

We are excited about the recent release of Progressive Web App (PWA) features in our App Platform, which you can read about in this blog post introducing them, and we think we have some interesting stories to share about their development. We faced interesting design challenges as we sought to make these features easily generalizable to any app, and the ways we used available technologies to solve those challenges are quite unique. The purpose of this post is to share our novel approach to managing service worker lifecycles and other PWA functionality in a generic way.

Contents

Let's start then with some necessary context about how our App Platform works.

DHIS2 App Platform

DHIS2 is used in many different countries and in many different contexts. Each DHIS2 instance has specific requirements, use-cases, and user experience workflows. We wanted to make it as easy as possible for developers in other organizations to extend the core functionality of DHIS2 by creating their own web applications (among other types of extensions) and also to share those apps with other implementers on our App Hub. We also wanted to make our own lives easier when creating and maintaining the more than 30 web applications developed by our core developer team.

Enter the App Platform. The App Platform is a unified application architecture and build pipeline to simplify and standardize application development within the DHIS2 ecosystem. The platform provides many common services and functionalities -- including authentication and authorization, translation infrastructure, common UI components, and a data access layer -- that are required by all DHIS2 web applications, making it easier and faster to develop custom applications without reinventing the wheel.

App Platform

Some features in this image are works in progress.

The App Platform at build-time

The App Platform consists of a number of build-time components and development tools that you can find in our app-platform repository:

  1. App Adapter: A wrapper for the app under development – it wraps the root component exported from the app's entry point (like <App />) and performs other jobs.
  2. App Shell: Provides the HTML skeleton for the app and other assets, imports the root <App> component from the app under development's entry point, and wraps it with the App Adapter. It also provides some environment variables to the app.
  3. App Scripts CLI: Provides development tools and performs build-time jobs such as building the app itself and running a development server. (also part of d2 global CLI)

The App Platform at run-time

At run-time, our platform offers React components and hooks that provide services to the app under development. These are mainly two libraries:

  1. The App Runtime library that uses a universal <Provider> component to provide context and support several useful services. The App Adapter adds the provider to apps using the platform by default. The services include:
    1. Data Service: Publishes a declarative API for sending and receiving data to and from the DHIS2 back-end
    2. Config Service: Exposes several app configuration parameters
    3. Alerts Service: Provides a declarative API for showing and hiding in-app alerts. This also coordinates with an Alerts Manager component in the App Adapter to show the UI
  2. A UI Library that offers reusable interface components that implement the DHIS2 design system. See more at the UI documentation and the ui repository.

The App Platform orchestra

To illustrate how the App Adapter, App Shell, and App Scripts CLI work together, consider this series of events that takes place when you initialize and build an app:

  1. Using the d2 global CLI, a new Platform app is bootstrapped using d2 app scripts init new-app in the terminal.
  2. Inside the new-app/ directory that the above script just created, the yarn build command is run which in turn runs d2-app-scripts build, which initiates the following steps. Any directory or file paths described below are relative to new-app/.
  3. i18n jobs are executed (out of scope for this post).
  4. The build script bootstraps a new App Shell in the .d2/shell/ directory.
  5. A web app manifest is generated.
  6. The app code written in src/ is transpiled and copied into the .d2/shell/src/D2App/ directory.
  7. Inside the Shell at this stage, the files are set up so that the root component exported from the entry point in the app under development (<App /> from src/App.js by default, now copied into .d2/shell/src/D2App/App.js) is imported by a file in the App Shell that wraps it with the App Adapter, and then the wrapped app gets rendered into an anchor node in the DOM.
  8. The shell-encapsulated app that's now set up in the .d2/shell/ directory is now basically a Create React App app, and react-scripts can be used to compile a minified production build. The react-scripts build script is run, and the build is output to the build/app/ directory in the app root.
  9. A zipped bundle of the app is also created and output to build/bundle/, which can be uploaded to a DHIS2 instance.

This example will be useful to refer back to when reading about the build process later in this article. Some details of this process may change as we improve our build tooling, but this is the current design as of writing.

To contextualize and preview the sections to come, here are the extensions we make to this process to add PWA features into the App Platform:

  • We add a service worker script to the App Shell that's bootstrapped in step 4
  • We generate a PWA manifest alongside the web app manifest in step 5
  • We extend the App Adapter in step 7 to support several client-side PWA features
  • The service worker script in the App Shell gets compiled and added to the build app during step 8

Into Progressive Web Apps (PWA)

Now that you have some background on our apps architecture and platform, let's talk about our implementation of Progressive Web App (PWA) technology and how it presented several design challenges as we developed it to be generalizable to any app. We wanted our App Platform-based web apps to support two defining features which are core to PWAs:

  • Installability, which means the app can be downloaded to a device and run like a native app, and
  • Offline capability, meaning the app can support most or all of its features while the device is offline. This works both when the app is opened in a browser or as an installed app.

Adding PWA features, especially offline capability, in the DHIS2 App Platform is a large task -- implementing PWA features can be complex enough in a single app, with some aspects being famously tricky.
On top of that, we have some other unique design criteria that add complexity to our project:

  • The features should work in and be easy to add to any Platform app.
  • They should provide tools that any app can use for managing caching of individual content sections. We call these tools Cacheable Sections and intend for them to support our Dashboard app's use-case of saving individual dashboards for offline usage.
  • They should not cause side effects for apps that don't use the PWA features.

For now, we'll cover installability and simple offline capability in this post. Cacheable sections are introduced in our PWA intro blog, but since they are more complex and face numerous particular design challenges, they will be described in another deep-dive post. Stay tuned to the DHIS2 developer's blog.

Adding installability

This is the simplest PWA feature to add; all that's needed is a PWA web manifest file which adds metadata about the web app so that it can be installed on a device, then to link to it from the app's index.html file like so:

<link
    rel="manifest"
    crossorigin="use-credentials"
    href="%PUBLIC_URL%/manifest.json"
/>
Enter fullscreen mode Exit fullscreen mode

In the App Platform, this is implemented by extending the manifest generation step of the App Scripts CLI build script (step 5 in the example build sequence above). The script accesses the app's config from d2.config.js and generates a manifest.json file with the appropriate app metadata, including name, description, icons, and theme colors; then writes that manifest.json to the resulting app's public/ directory, which would be .d2/shell/public/. You can take a peek at the manifest generation source code in the App Scripts CLI here.

Then, the App Shell package contains the index.html file that the app will use, so that's where the link to the manifest.json file will be added.

All Platform apps generate a PWA web manifest, even if PWA is not enabled, but this alone will not make the app installable. A service worker with a 'fetch' handler must be registered too, which is rather complex and described below.

Adding simple offline capability

Basic offline capability is added to the platform by adding a service worker to the app. A service worker is a script that installs and runs alongside the app and has access to the app's network traffic by listening to fetch events from the app, then handles what to do with the requests and responses it receives.

The service worker can maintain offline caches with data that the app uses. Then, when the user's device is offline and the app makes a fetch event to request data, the service worker can use the offline cache to respond to the request instead needing to fetch that data over the network. This allows the app to work offline. You can read more about the basics of service workers here; the following sections assume some knowledge about the basics of how they work.

Implementing the service worker in the app platform takes several steps:

  1. Creating a service worker script to perform offline caching
  2. Compiling the service worker and adding it to the app
  3. Registering the service worker from the app if PWA is enabled in the app's config
  4. Managing the service worker's updates and lifecycle

Creating a service worker script to perform offline caching

We use the Workbox library and its utilities as a foundation for our service worker.

There are several different strategies available for caching data offline that balance performance, network usage, and data freshness. We settled on these strategies to provide basic offline functionality in Platform apps:

  1. Static assets that are part of the built app (javascript, CSS, images, and more) are precached.
  2. Data that's requested during runtime always uses the network with a combination of a stale-while-revalidate strategy for fetched image assets and a network-first strategy for other data.

If you want to read more about our decisions to use these strategies, they are explained in more depth in our first PWA blog post.

Compiling the service worker and adding it to the app

An implementation constraint for service workers is that they must be a single, self-contained file when they are registered by the app to get installed in a user's browser, which means all of the service worker code and its dependencies must be compiled into a single file at build time. Our service worker depends on several external packages and is split up among several files to keep it in digestible chunks before being imported in the App Shell, so we need some compilation tools in the Platform.

Workbox provides a Webpack plugin that can compile a service worker and then output the production build to the built app. Our build process takes advantage of Create React App (CRA)'s build script for the main compilation step once the app under development has been injected into our App Shell, and CRA happens to be configured out-of-the-box to use the Workbox-Webpack plugin to compile a service worker. It compiles a service-worker.js file in the CRA app's src/ directory and outputs it into the built app's public/ directory, so most of our compilation needs are met by using CRA.

The Workbox-Webpack plugin also injects a precache manifest into the compiled service worker, which is a list of the URLs that the service worker will fetch and cache upon installation. The plugin uses the list of minified static files that Webpack outputs from the build process to make this manifest, which covers the app's javascript and CSS chunks as well as the index.html file.

These do not cover all of the static assets in the app's build directory however; other files like icons, web manifests, and javascript files from vendors like jQuery need to be handled separately. To add those remaining files to the precache manifest, we added another step to our CLI's build process. After executing the CRA build step, we use the injectManifest function from the workbox-build package to read all of the other static files in the app's build directory, generate a manifest of those URLs, and inject that list into the compiled service worker at a prepared placeholder. You can see the resulting injectManifest code here.

Handling these precache manifests correctly is also important for keeping the app up-to-date, which will be described in the "Managing the service worker's updates and lifecycle" section below.

Using a config option to enable PWA features

To implement the opt-in nature of the PWA features, the service worker should only be registered if PWA is enabled in the app's configuration. We added an option to the d2.config.js app config file that can enable PWA, which looks like this:

// d2.config.js
module.exports = {
    type: 'app',
    title: 'My App',

    // Add this line:
    pwa: { enabled: true },

    entryPoints: {
        app: './src/App.js',
    },
}
Enter fullscreen mode Exit fullscreen mode

During the d2-app-scripts start or build processes, the config file is read, and a PWA_ENABLED value is added to the app's environment variables. Then, in the App Adapter's initialization logic, it registers or unregisters the service worker based on the the PWA_ENABLED environment variable.

The registration logic is described in more detail in the "Registration of the service worker" section below.

Managing the service worker's updates and lifecycle

Managing the service worker's lifecycle is both complex and vitally important. Because the service worker is responsible for serving the app from cached files, it now has a role in what version of the app a user sees. Note that the service worker serves the app from a list of files that's set at the time it gets compiled. Because of this, the service worker itself needs to be updated in a user's browser in order to serve an updated version of the app.

If the service worker lifecycle and updates are managed poorly, the app can get stuck on an old version in a user's browser and never receive updates from the server. This can be hard to diagnose and harder to fix. The "Handling precached static assets between versions" section below explains more about why that happens.

Managing PWA updates can be a famously tricky problem, and we think we've come across a robust system to handle it which we'll describe below.

Designing a good user experience for updating PWA apps

Managing service worker updates is complex from a UX perspective: we want the user to use the most up-to-date version of the app possible, but updating the service worker to activate new app updates in production requires a page reload, for reasons described below. Reloading can cause loss of unsaved data on the page, so we don't want to do that without the user's consent. Therefore, it poses a UX design challenge to notify and persuade users to reload the app to use new updates as soon as possible, and at the same time avoid any dangerous, unplanned page reloads.

What's more, we want to do so in the least invasive way possible, ideally without the user needing to think about anything technical. A notification like "An update is available" would be too invasive, and would even look suspicious to some users.

To address these needs, the UX design we settled on is this:

  1. First, if a service worker has installed and is ready, we won't activate it right away. We'll wait and try to sneak in an update without the user needing to do anything, if possible. What happens next depends on a few conditions.
  2. If this is the first time a service worker is installing for this app, then any page reload will take advantage of the installed service worker, and PWA features will be ready in that reloaded page. If multiple tabs are open, they will each need to be reloaded to use the service worker and PWA features.
  3. If the newly installed service worker is an update to an existing one, however, reloading will not automatically activate the waiting service worker.
    1. If there is only one tab of this app open, then it's possible to safely sneak in the update the next time the user loads the page. Before loading the main interactive part of the app, the app shell checks for a waiting service worker, activates it if there is one, and then reloads, so the service worker can be safely updated without interfering with the user's activity.
    2. If the user has multiple tabs of the app open, however, we can't sneak in a quick update and reload. This is because the active service worker controls all the active tabs at the same time, so to activate the new service worker, all the tabs need to be reloaded simultaneously. Reloading all of the tabs without the user's permission may lose unsaved data in the other open tabs, so we don't want to do that. In this case, we rely on the next to options to happen.
  4. If a new service worker is installed and waiting to take over, a notification will be visible at the bottom of the user's profile menu. If they click it, the waiting service worker will be directed to take over, and the page will reload. Update available notification If there are multiple tabs open, a warning will be shown that all the tabs will reload, so the data in those tabs should be saved before proceeding. If possible, the number of tabs is shown in the modal to help the user account for forgotten tabs, as could happen if the user has many browser windows open or is on a mobile device. Reload confirmation modal
  5. If none of the above cases happen, then the app will rely on the native browser behavior: after all open tabs of the app in this browser are closed, the new service worker will be active the next time the app is opened.

There are also two improvements that we're working on implementing to improve this UX:

  1. When a new service worker is waiting, a badge will be shown on the user profile icon in the header bar to indicate that there's new information to check
  2. Before any service worker is controlling the app, some UI element in the header bar will indicate that PWA features aren't available yet

Implementation of the app update flow

Implementing this update flow in the App Platform requires several cooperating features and lots of logic behind the scenes in the service worker code, the client-side service worker registration functions, and the React user interface.

To simplify communicating with the service worker from the React environment and abstract away usage of the navigator.serviceWorker APIs, we made an Offline Interface object that handles event-based communication with the service worker and exposes easier-to-use methods for registration and update operations. It also provides some functions that serve cacheable sections and complex offline capability which will be described in more detail in a follow-up PWA blog post.

Our service worker registration functions draw much from the Create React App PWA Template registration boilerplate, which includes some useful logic like checking for a valid service worker, handling development situations on localhost, and some basic update-checking procedures. These features were a useful starting place, but our use-case required more complexity, which lead to the elaborations described below.

Registration of the service worker

If PWA is enabled, a register() function is called when an Offline Interface object is instantiated in the App Adapter while the app is loading. The register() function listens for the load event on the window object before calling navigator.serviceWorker.register() to improve page load performance: the browser checks for a new service worker upon registration, and if there is one, the service worker will download and install any app assets it needs to precache. These downloads can be resource intensive, so they are delayed to avoid interfering with page responsiveness on first load.

The Offline Interface also registers a listener to the controllerchange event on navigator.serviceWorker that will reload the page when a new service worker takes control, i.e. starts handling fetch events. This is to make sure the app loads by using the latest precached assets.

Unlike some implementations, our service worker is designed to wait patiently once it installs. After it installs and activates for the first time, it does not claim the open clients, i.e. take control of those pages and start handling fetch events by using the clients.claim() API; instead it waits for the page to reload before taking control. This design ensures that a page is only ever controlled during its lifetime by one service worker or none; a reload is required for a service worker to take control of a page that was previously uncontrolled or to take over from a previous one. This makes sure the app only uses the core scripts and assets from one version of the app. The service worker also does not automatically skip waiting and take control of a page when a new update has installed; it will continue waiting for a signal from the app or for the default condition described in part 4 of the UX flow above. What the service worker does do is listen for messages from the client instructing it to claim clients or skip waiting, which are sent went the user clicks the "Click to reload" option in the profile menu. The listeners look like this:

self.addEventListener('message', (event) => {
    if (event.data.type === 'CLAIM_CLIENTS') {
        // Calls clients.claim() and reloads all tabs:
        claimClients()
    }
    if (event.data.type === 'SKIP_WAITING') {
        self.skipWaiting()
    }
})
Enter fullscreen mode Exit fullscreen mode

'CLAIM_CLIENTS' is used the first time a service worker has installed for this app, and 'SKIP_WAITING' is used when an updated service worker is installed and ready to take over. Below you can see more details about these messages.

Automatically applying app updates when possible

The PWALoadingBoundary component enables the app to sneak in app updates upon page load in most cases without the user needing to know or do anything. It's implemented in the App Adapter and is supported by the Offline Interface. It wraps the rest of the app, and before rendering the component tree below it, it checks if there is a new service worker waiting to take over. If there is one, and only one tab of the app is open, it can instruct the new service worker to take over before loading the rest of the app. This allows the app to update and reload safely and without interfering with the user's work.

export const PWALoadingBoundary = ({ children }) => {
    const [pwaReady, setPWAReady] = useState(false)
    const offlineInterface = useOfflineInterface()

    useEffect(() => {
        const checkRegistration = async () => {
            const registrationState =
                await offlineInterface.getRegistrationState()
            const clientsInfo = await offlineInterface.getClientsInfo()
            if (
                (registrationState === REGISTRATION_STATE_WAITING ||
                    registrationState ===
                        REGISTRATION_STATE_FIRST_ACTIVATION) &&
                clientsInfo.clientsCount === 1
            ) {
                console.log(
                    'Reloading on startup to activate waiting service worker'
                )
                offlineInterface.useNewSW()
            } else {
                setPWAReady(true)
            }
        }
        checkRegistration().catch((err) => {
            console.error(err)
            setPWAReady(true)
        })
    }, [offlineInterface])

    return pwaReady ? children : null
}
Enter fullscreen mode Exit fullscreen mode

Upon render, the Loading Boundary first checks for any new service workers by using the Offline Interface's getRegistrationState() method, a convenience method for accessing the getRegistrationState() registration function. getRegistrationState() is a simplified check for service workers' installation status, intended to determine if there's a new service worker ready right now. It returns one of several values: 'UNREGISTERED', 'WAITING' if there is an updated service worker ready, 'FIRST_ACTIVATION' if this is the first time a service worker has installed, or 'ACTIVE' if there's already a service worker in control and none currently waiting.

Then, to check how many tabs of the app are open, the PWALoadingBoundary uses the Offline Interface's getClientsInfo() method, which "asks" the ready service worker how many clients are associated with this service worker scope. To get this info accurately in every situation, the service worker needs to perform some special checks, as shown in the code below.

/** Get all clients including uncontrolled, but only those within SW scope */
export function getAllClientsInScope() {
    // Include uncontrolled clients: necessary to know if there are multiple
    // tabs open upon first SW installation
    return self.clients
        .matchAll({
            includeUncontrolled: true,
        })
        .then((clientsList) =>
            // Filter to just clients within this SW scope, because other clients
            // on this domain but outside of SW scope are returned otherwise
            clientsList.filter((client) =>
                client.url.startsWith(self.registration.scope)
            )
        )
}
Enter fullscreen mode Exit fullscreen mode

The service worker uses the self.clients.matchAll() API with the includeUncontrolled option, since some tabs may be uncontrolled the first time the service worker installs. Then, since that function returns every open client on this domain, even ones outside of the scope of the service worker's control, the resulting clients need to be filtered down to just the clients in scope. After the service worker gets the right clients list, it posts a message back to the client to report the clients info. Then, the getClientsInfo() method returns a promise that either resolves to the clients info or rejects with a failure reason.

If there is a service worker waiting to take over (either the 'WAITING' or 'FIRST_ACTIVATION' conditions above), and there is only one tab of the app open, the PWALoadingBoundary will apply the ready update by calling the useNewSW() method on the Offline Interface. The method instructs the new service worker to take over: it detects if this new service worker is the first one that has installed for this app or an update to an existing service worker, then sends either a 'CLAIM_CLIENTS' message to a first-install service worker or a 'SKIP_WAITING' message to an updated service worker. Skipping waiting or claiming clients by the service worker both result in a controllerchange event in open clients, which triggers the event listener that the Offline Interface set up on navigator.serviceWorker to listen for that event (recall from the "Registration of the service worker" section). The listener will then call window.location.reload() to reload the page so that the page can load under the control of the new service worker.

If there isn't a new service worker or if there are multiple tabs open, then the rest of the app will load as normal. By doing this check before loading the app, the app can apply PWA updates without the user needing to do anything in most cases, which is a nice win for the user experience.

Providing the UI for manually applying updates

The usePWAUpdateState hook provides the logic to support the UI for applying updates manually, and the ConnectedHeaderBar component connects the hook to the relevant UI components. Like the PWALoadingBoundary component, the hook and the ConnectedHeaderBar component are implemented in the App Adapter and are supported by the Offline Interface. The code for both is shown below — look closely at the usePWAUpdateState hook's onConfirmUpdate() function, the confirmReload() function, and the useEffect() hook.

export const usePWAUpdateState = () => {
    const offlineInterface = useOfflineInterface()
    const [updateAvailable, setUpdateAvailable] = useState(false)
    const [clientsCount, setClientsCount] = useState(null)

    const onConfirmUpdate = () => {
        offlineInterface.useNewSW()
    }
    const onCancelUpdate = () => {
        setClientsCount(null)
    }

    const confirmReload = () => {
        offlineInterface
            .getClientsInfo()
            .then(({ clientsCount }) => {
                if (clientsCount === 1) {
                    // Just one client; go ahead and reload
                    onConfirmUpdate()
                } else {
                    // Multiple clients; warn about data loss before reloading
                    setClientsCount(clientsCount)
                }
            })
            .catch((reason) => {
                // Didn't get clients info
                console.warn(reason)

                // Go ahead with confirmation modal with `0` as clientsCount
                setClientsCount(0)
            })
    }

    useEffect(() => {
        offlineInterface.checkForNewSW({
            onNewSW: () => {
                setUpdateAvailable(true)
            },
        })
    }, [offlineInterface])

    const confirmationRequired = clientsCount !== null
    return {
        updateAvailable,
        confirmReload,
        confirmationRequired,
        clientsCount,
        onConfirmUpdate,
        onCancelUpdate,
    }
}
Enter fullscreen mode Exit fullscreen mode
export function ConnectedHeaderBar() {
    const { appName } = useConfig()
    const {
        updateAvailable,
        confirmReload,
        confirmationRequired,
        clientsCount,
        onConfirmUpdate,
        onCancelUpdate,
    } = usePWAUpdateState()

    return (
        <>
            <HeaderBar
                appName={appName}
                updateAvailable={updateAvailable}
                onApplyAvailableUpdate={confirmReload}
            />
            {confirmationRequired ? (
                <ConfirmUpdateModal
                    clientsCount={clientsCount}
                    onConfirm={onConfirmUpdate}
                    onCancel={onCancelUpdate}
                />
            ) : null}
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

By using the useEffect hook with an empty dependency array, upon first render, the usePWAUpdateState hook checks for new service workers by calling the Offline Interface's checkForNewSW() method, which basically just exposes the checkForUpdates() registration function. Compared to the the getRegistrationState() function that the PWALoadingBoundary uses, checkForUpdates() is more complex, since it checks for service workers installed and ready, listens for new ones becoming available, and checks for installing service workers between those states. We need to check a number of variables to handle all the possible installation conditions:

  • Service workers can be in one of the four steps of their lifecycle: installing, installed, activating, or activated
  • Multiple service workers can be simultaneously present in the service worker registration object as either installing, waiting, or active
  • Sometimes the active service worker is not in control because it's the first service worker installation for this app

For the full control flow, take a look at the checkForUpdates() source code.

If there is a new service ready, then the onNewSW() callback function provided as an argument to checkForNewSW() is called, which sets the updateAvailable boolean returned by the hook to true. The ConnectedHeaderBar component passes this value as a prop to the HeaderBar, which shows the "New app version available — Click to reload" notification in the user profile menu.

Update available notification

If the user opens the profile menu and clicks the "New version available" notification, the confirmReload() function in usePWAUpdateState is called. It handles the next part of the update flow by checking how many tabs of this app are open, so that if multiple tabs are open, a warning can be shown that they will all be reloaded. Like the PWALoadingBoundary, it uses the Offline Interface's getClientsInfo() method to get the number of clients associated with this service worker.

Once the clients info is received, if there is one client open for this service worker scope, confirmReload() will use the Offline Interface's useNewSW() method to instruct the new service worker to take control, as the PWALoadingBoundary does. If there are multiple clients open, or if the getClientsInfo() request fails, then the confirmationRequired boolean returned by the usePWAUpdateState hook will resolve to true. In the ConnectedHeaderBar component, this will result in rendering the ConfirmReloadModal that warns about data loss when all open tabs will be reloaded.

Reload confirmation modal

If the user clicks "Reload" in the modal, the onConfirmUpdate() function is called, which calls the offlineInterface.useNewSW() function and the update is triggered. If the user clicks "Cancel", the onCancelUpdate() function is called, which resets the confirmationRequired boolean to false by setting clientsCount to null, which will close the modal.

All these steps under the hood are coordinated to create the robust user experienc described above and make sure service workers and apps update correctly.

Handling precached static assets between versions

As mentioned in the "Compiling the service worker" section above, when using precaching for app assets, there are several considerations that should be handled correctly with respect to app and service worker updates. Conveniently, these best practices are handled by the Workbox tools (the Webpack plugin and the workbox-build package) introduced earlier.

When using a precaching strategy, it's possible for an app to get stuck on old version in a user's client, even though there's a new version of the app on the server. Since precached assets will be served directly from the cache without accessing the network, new app updates will never be accessed until the service worker itself updates, downloads the new assets to use, and serves them.

To get the service worker to update, the script file on the server needs to be byte-different from the the file of the same name that's running on the client (service-worker.js, in our case). The browser checks for service worker updates upon navigation events in the service worker's scope or when the navigator.serviceWorker.register function is called. To make sure updates in app files on the server end up in clients' browsers, revision info is added to filenames in the service worker's precache manifest, if the filename doesn't already have it. When an app file is changed, the content hash will change in the precache manifest, and thus the contents of the service-worker.js file will be different.

Now, when a user's browser checks the service-worker.js file on the server, it will be byte-different, and the client will download and install new app assets to use.

You can read more about precaching with Workbox at the Workbox documentation.

Adding a kill switch for a rogue service worker

In some cases, a service worker lifecycle can get out of control and an app can be stuck with a service worker serving old app assets. If the app doesn't detect a new service worker and doesn't offer the user the option to reload, the app in the user's browser will not be updated. This can be a difficult problem to debug, and requires manual steps by the user to resolve. As described in this article, we have worked hard to build our application platform in such a way that apps don't need to do anything special to deal with service worker updates -- it is all handled in the platform layer and the Offline Interface. We sometimes encounter this problem when an old version of an app once registered a service worker and served the app assets via a precaching strategy. Then, when a new version of the app is deployed without a service worker, there is no way for the newly deployed app to take over from the previous version. It would seem like the app was stuck on an old version and missing new fixes, even though a new version had been deployed to the server.

To handle this rogue service worker case, we added a kill-switch mode to the service worker in the platform which will help unstick apps with a service worker that's serving an old version of the app. This takes advantage of browsers' service worker update design: in response to a registration event or a navigation in scope of an active service worker, the browser will check the server for a new version of the service worker with the same filename, even if that service worker is cached. If there is a service worker on the server and it is byte-different from the active one, the browser will initiate the installation process of the new service worker downloaded from the server (this was relevant to the update process described above as well).

To take advantage of that process, every Platform app actually gets a compiled service worker called service-worker.js added to the built app, whether or not PWA is enabled. This helps a non-PWA app take over from and uninstall a PWA app that's installed in a user's browser. For non-PWA apps, the service worker will run this code if it does get installed to take over from a PWA app:

/** Called if the `pwaEnabled` env var is not `true` */
export function setUpKillSwitchServiceWorker() {
    // A simple, no-op service worker that takes immediate control and tears
    // everything down. Has no fetch handler.
    self.addEventListener('install', () => {
        self.skipWaiting()
    })

    self.addEventListener('activate', async () => {
        console.log('Removing previous service worker')
        // Unregister, in case app doesn't
        self.registration.unregister()
        // Delete all caches
        const keys = await self.caches.keys()
        await Promise.all(keys.map((key) => self.caches.delete(key)))
        // Delete DB
        await deleteSectionsDB()
        // Force refresh all windows
        const clients = await self.clients.matchAll({ type: 'window' })
        clients.forEach((client) => client.navigate(client.url))
    })
}
Enter fullscreen mode Exit fullscreen mode

It will skip waiting as soon as it's done installing to claim all open clients, and upon taking control, will unregister itself, delete all CacheStorage caches and a "sections" IndexedDB that will be introduced in a follow-up post about Cacheable Sections, then reload the page. After this reload, the service worker will be inactive, and the new app assets will be fetched from the server instead of served by the offline cache, allowing the app to run normally.

Ultimately, by including this kill-switch mode, we prevent apps from getting stuck in the future and we unstick apps that have been stuck in the past.

Be aware, however, that this might cause some loss of data if your app is also using the CacheStorage or Cacheable Section tools. It's highly unusual for a kill-switch worker to activate however, so running into such a problem is highly unlikely, but we want to point it out for the few developers who may be using those tools.

Conclusion

We hope you enjoyed this introduction to the DHIS2 App Platform and to its PWA features. We covered installability, build tooling to read an app's config and compile a service worker, caching strategies, and service worker updates and lifecycle management. Many of the challenges and solutions we described are applicable to any PWA application developer. We hope that you have also come away with a deeper understanding of how these features work together to enable offline capability in DHIS2 apps. If you found this post interesting or useful please leave a comment below!

In a follow-up post we'll describe design challenges and solutions for creating the Cacheable Sections and some other App Runtime features that were described in the PWA introduction blog post (stay tuned to the DHIS2 developer's blog and follow here).

Is there anything you'd like to know more about on this subject, or have any other questions or comments? Feel free to reach out to us via e-mail, Slack, Twitter or our Community of Practice! We're always happy to hear from interested developers and community members. If you would like to join our team to tackle challenges like the PWA implementation please check our careers section in our website. All of our software team roles are remote-friendly, and we encourage people of all identities and backgrounds to apply.

Top comments (1)

Collapse
 
wraldpyk profile image
Rene Pot

Great article! This really goes to show the complexity of making something dynamic but intuitive.