25 {shinyMobile} and PWA

Transforming a classic Shiny app into a PWA is a game changer for end users. The first step is to setup a valid web manifest with icons and favicon.

Some of the PWA features won’t work with iOS, like the install prompt.

As a reminder, the code examples shown throughout this chapter are gathered in the {OSUICode} package accessible here, specifically PWA apps are available here.

25.1 Introduction

Below, we review one by one the necessary steps to convert a shiny app to a PWA. To get a good idea of what our mission exactly is, we leverage the Application tab of the developer tools.

The overall expected result is shown Figure 25.1. Alternatively, one may use the Google Lighthouse utility to provide a general diagnosis to the app, as illustrated on Figure 25.2. There are many categories like performance, accessibility. In our case, let’s just select the PWA category, check the mobile device radio and click on generate a report.

Application tab of the developers tools

FIGURE 25.1: Application tab of the developers tools

Google Lightouse utility

FIGURE 25.2: Google Lightouse utility

According to the diagnostic result displayed on Figure 25.3, we don’t meet all requirements, most importantly there is:

  • No manifest.
  • No service worker.
  • No icons.
Lighthouse audit result

FIGURE 25.3: Lighthouse audit result

25.2 {charpente} and PWA tools

charpente has tools to help designing a PWA, particularly the set_pwa function, that does all the previously mentioned step in only one line of code. There are however few prerequisites:

  • The app must belong to a package.
  • The function must target the app directory.

We create the pwa-app sub-folder and the app.R file:

library(shiny)
library(shinyMobile)

ui <- f7_page(
  "Test",
  navbar = f7_navbar("Title"),
  toolbar = f7_toolbar(),
  title = "shinyMobile"
)

server <- function(input, output, session) {}
shinyApp(ui, server)

Then we set the PWA configuration:

set_pwa("inst/examples/pwa-app")

This function generates a manifest.webmanifest file, downloads the Google PWA compatibility script, adds a custom dependency pointing to the manifest.webmanifest file and a 144x144 icon file, copies a boilerplate service-worker.js with its offline.html page and optionally registers the service worker (whose code is borrowed from web.dev:

window.addEventListener('load', () => {
  if ('serviceWorker' in navigator) {
    var pathname = window.location.pathname;
    navigator.serviceWorker
      .register(pathname + 'service-worker.js', { scope: pathname})
      .then(function() { console.log('Service Worker Registered'); });
  };
});

In the shinyMobile case, as Framework7 already registers any provided service worker, we don’t need that initialization script. Therefore, we actually call:

set_pwa("inst/examples/pwa-app", register_service_worker = FALSE)

Importantly, this functions does not handle icon creation. There are tools such as appsco and app-manifest, to create those custom icons and splash screens, if you need to.

In the following, we provide more detail about the mentioned steps.

25.2.1 Create the manifest

We would like to create a JSON configuration file like this:

{
  "short_name": "My App",
  "name": "Super amazing app",
  "description": "This app is just mind blowing",
  "icons": [
    {
      "src": "icons/icon.png",
      "type": "image/png",
      "sizes": "192x192"
    }
    // ...
  ],
  "start_url": "https://whatever-url.com/",
  "background_color": "#3367D6",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#3367D6"
}

This file has to be accessible by the app, hence best practice is to put it in the /www folder, images being hosted in the /www/icons sub-directory. The charpente create_manifest() function writes a JSON file at the provided location:

create_manifest <- function(
  path, 
  name = "My Progressive Web App", 
  shortName = "My App",
  description = "What it does!", 
  lang = "en-US",
  startUrl = "/", 
  display = c("minimal-ui", "standalone", "fullscreen", "browser"),
  background_color = "#ffffff", 
  theme_color = "#ffffff"
) {

  display <- match.arg(display)

  manifest <- list(
    name = name,
    short_name = shortName,
    description = description,
    lang = lang,
    start_url = startUrl,
    display = display,
    background_color = background_color,
    theme_color = theme_color,
    icons = data.frame(
      src = "icons/icon-144.png",
      sizes = "144x144"
    )
  )

  # create /www folder if does not exist yet
  if (!dir.exists(paste0(path, "/www"))) {
    dir.create(paste0(path, "/www/icons"), recursive = TRUE)
  }
  jsonlite::write_json(
    manifest, 
    path = paste0(path, "/www/manifest.webmanifest"),
    pretty = TRUE,
    auto_unbox = TRUE
  )
  
  ui_done("Web manifest successfully created!")
}

The web manifest and icons have to be included in the head before the Google PWA compatibility script:

<link rel="manifest" href="manifest.webmanifest" />
<!-- include icon also from manifest -->
<link rel="icon" type="image/png" 
  href="icons/logo-144.png" sizes="144x144" />

set_pwa() includes a create_pwa_dependency() function that creates an HTML dependency containing all necessary resources:

#' PWA dependencies utils
#'
#' @description This function attaches PWA manifest and icons to the given tag
#'
#' @param tag Element to attach the dependencies.
#'
#' @importFrom utils packageVersion
#' @importFrom htmltools tagList htmlDependency
#' @export
add_pwa_deps <- function(tag) {
 pwa_deps <- htmlDependency(
  name = "pwa-utils",
  version = packageVersion("shinyMobile"),
  src = c(file = "shinyMobile-0.0.0.9000"),
  head = "<link rel=\"manifest\" 
    href=\"manifest.webmanifest\"/>
    <link rel=\"icon\" type=\"image/png\" 
    href=\"icons/icon-144.png\" sizes=\"144x144\" />",
  package = "mypkg2",
 )
 tagList(tag, pwa_deps)
}

In practice, since the package already relies on other dependencies like Framework7, we will leverage the add_dependencies() function to add all dependencies at once.

All provided icons must follow the convention icon-<size_in_px>.png like icon-144.png, which is the default.

25.2.2 Google PWA compatibility

As we use the Google PWA compatibility script, we have to include at least one icon like <link rel="icon" type="image/png" href="res/icon-128.png" sizes="128x128" />. However, we found some discrepancies between the developer tools recommendations and the PWA compatibility script. Therefore, we recommend to follow the developer tools prescriptions, that is to include at least one icon of size 144x144. All other elements are generated by the script itself, which is convenient. Indeed, having to handle all possible screen sizes and different OS is particularly tricky, repetitive and not interesting.

The HTML dependency is downloaded with create_dependency("pwacompat", options = charpente_options(bundle = FALSE)). Don’t forget to update the add_dependencies() list by including the two new dependencies:

f7_page <- function(..., navbar, toolbar, title = NULL, 
                    options = NULL) {

  config_tag <- tags$script(
    type = "application/json",
    `data-for` = "app",
    jsonlite::toJSON(
      x = options,
      auto_unbox = TRUE,
      json_verbatim = TRUE
    )
  )

  # create body_tag
  body_tag <- tags$body(
    tags$div(
      id = "app",
      tags$div(
        class = "view view-main",
        tags$div(
          class = "page",
          navbar,
          toolbar,
          tags$div(
            class = "page-content",
            ...
          )
        )
      )
    ),
    config_tag
  )

  tagList(
    tags$head(
      tags$meta(charset = "utf-8"),
      tags$meta(
        name = "viewport",
        content = "width=device-width, initial-scale=1, 
        maximum-scale=1, minimum-scale=1, user-scalable=no, 
        viewport-fit=cover"
      ),
      tags$meta(
        name = "apple-mobile-web-app-capable",
        content = "yes"
      ),
      tags$meta(
        name = "theme-color",
        content = "#2196f3"
      ),
      tags$title(title)
    ),
    add_dependencies(
      body_tag,
      deps = c("framework7", "shinyMobile", "pwa", "pwacompat")
    )
  )
}

If you do devtools::load_all() and run the app again, you should see the new dependencies in the head (Figure 25.4).

New PWA dependencies in the head tag.

FIGURE 25.4: New PWA dependencies in the head tag.

Yet, according to Figure 25.5, we still miss the service worker, as shown in the manifest diagnostic. This demonstrates how powerful are the developer tools as the end user is always guided step by step.

Missing service worker registration.

FIGURE 25.5: Missing service worker registration.

25.2.3 Service worker and offline page

The second mandatory step to make our app installable is the service worker. We borrowed the code from web.dev. set_pwa() copies this code in the the provided app /www folder:

/*
Copyright 2015, 2019, 2020 Google LLC. All Rights Reserved.
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 http://www.apache.org/licenses/LICENSE-2.0
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
*/

// Incrementing OFFLINE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
const OFFLINE_VERSION = 1;
const CACHE_NAME = "offline";
// Customize this with a different URL if needed.
const OFFLINE_URL = "offline.html";

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      // Setting {cache: 'reload'} in the new request will ensure that the
      // response isn't fulfilled from the HTTP cache; i.e., it will be from
      // the network.
      await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
    })()
  );
  // Force the waiting service worker to become the active service worker.
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Enable navigation preload if it's supported.
      // See https://developers.google.com/web/updates/2017/02/navigation-preload
      if ("navigationPreload" in self.registration) {
        await self.registration.navigationPreload.enable();
      }
    })()
  );

  // Tell the active service worker to take control of the page immediately.
  self.clients.claim();
});

self.addEventListener("fetch", (event) => {
  // We only want to call event.respondWith() if this is a navigation request
  // for an HTML page.
  if (event.request.mode === "navigate") {
    event.respondWith(
      (async () => {
        try {
          // First, try to use the navigation preload response if it's supported.
          const preloadResponse = await event.preloadResponse;
          if (preloadResponse) {
            return preloadResponse;
          }

          // Always try the network first.
          const networkResponse = await fetch(event.request);
          return networkResponse;
        } catch (error) {
          // catch is only triggered if an exception is thrown, which is likely
          // due to a network error.
          // If fetch() returns a valid HTTP response with a response code in
          // the 4xx or 5xx range, the catch() will NOT be called.
          console.log("Fetch failed; returning offline page instead.", error);

          const cache = await caches.open(CACHE_NAME);
          const cachedResponse = await cache.match(OFFLINE_URL);
          return cachedResponse;
        }
      })()
    );
  }

  // If our if() condition is false, then this fetch handler won't intercept the
  // request. If there are any other fetch handlers registered, they will get a
  // chance to call event.respondWith(). If no fetch handlers call
  // event.respondWith(), the request will be handled by the browser as if there
  // were no service worker involvement.
});

This service worker redirects the end user to the offline cached page (offline.html) whenever the app is offline, thereby offering a better user experience.

We strongly advise to keep the same file names.

The next step involves the service worker registration. Framework7 has a dedicated module in the app configuration. We modify init.js and run build_js() to update the minified file:

serviceWorker: {
  path: window.location.pathname + 'service-worker.js',
  scope: window.location.pathname
}

If the process is successful, you get the result shown in Figure 25.6.

Registered service worker.

FIGURE 25.6: Registered service worker.

The new standard imposes to return a valid response when the app is offline. The offline page is also copied from charpente:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, 
    initial-scale=1" />

    <title>You are offline</title>

    <!-- inline the webpage's stylesheet -->
    <style>
      body {
        font-family: helvetica, arial, sans-serif;
        margin: 2em;
      }

      h1 {
        font-style: italic;
        color: #373fff;
      }

      p {
        margin-block: 1rem;
      }

      button {
        display: block;
      }
    </style>
  </head>
  <body>
    <h1>You are offline</h1>

    <p>Click the button below to try reloading.</p>
    <button type="button">⤾ Reload</button>

    <!-- inline the webpage's javascript file -->
    <script>
      document
        .querySelector("button")
        .addEventListener("click", () => {
          window.location.reload();
      });
    </script>
  </body>
</html>

Now, let’s audit our app again: congrats! It is installable, reliable, although further PWA optimization may be provided.

Installable shinyMobile app.

FIGURE 25.7: Installable shinyMobile app.

A common source of error is the browser cache. It is best practice to regularly empty the it. Alternatively, one may runs in incognito mode, which does not cache files.

25.2.4 Disable PWA for the end user

With the above approach, shinyMobile will always look for a service worker to register. Particularly, this would raise an error in case no service worker is found on the server. What if the user don’t want to create a PWA, let’s say for less important applications? We may add a parameter to f7_page, for instance allowPWA, that is either TRUE or FALSE, store its value in the body data-pwa attribute and recover it on the JS side within init.js:

// check if the app is intended to be a PWA
let isPWA = $('body').attr('data-pwa') === "true";

if (isPWA) {
  config.serviceWorker = {
    path: window.location.pathname + "service-worker.js",
    scope: window.location.pathname
  };
}

It only creates config.serviceWorker if the user specified allowPWA = TRUE.

25.3 Handle the installation

It is a great opportunity to propose a custom installation experience.

To be able to install the app, make sure to replace start_url by the url where the app is deployed like https://dgranjon.shinyapps.io/installable-pwa-app/ for instance. Missing that step would cause an issue during the service worker registration.

Once the installation criteria are met, the web browser raises the beforeinstallprompt event, except on the iOS platform, that is not yet compatible. We edit the init.js script and write:

let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the mini-infobar from appearing on mobile
  e.preventDefault();
  // Stash the event so it can be triggered later.
  deferredPrompt = e;
});

This code adds an event listener to the window, prevents it from showing at start with e.preventDefault and captures it in an external variable called deferredPrompt. The next step comprises the design of our custom piece of UI that will trigger the prompt install. We can benefit from the rich Framework7 interface and display a toast containing an install button. The initialization is fairly simple, following the pattern app.<COMPONENT>.create(parameters):

// Create custom install UI
let installToast = app.toast.create({
  position: 'center',
  text: `<button 
    id="install-button" 
    class="toast-button button color-green">
    Install
  </button>`
});

We give it an id so as to call it later and edit the beforeinstallprompt event listener to show the toast:

let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the mini-infobar from appearing on mobile
  e.preventDefault();
  // Stash the event so it can be triggered later.
  deferredPrompt = e;
  // Show install trigger
  installToast.open();
});

With jQuery like $(window).on('beforeinstallprompt', ...), we would capture the event with e.originalEvent!

We register a second event listener, which fires on the toast button click. We first close the toast, call the prompt method on the deferred event and log the result:

app.utils.nextTick(function() {
  $('#install-button').on('click', function() {
    // close install toast
    installToast.close();
    if (!deferredPrompt) {
      // The deferred prompt isn't available.
      return;
    }
    // Show the install prompt.
    deferredPrompt.prompt();
    // Log the result
    deferredPrompt.userChoice.then((result) => {
      console.log('👍', 'userChoice', result);
      // Reset the deferred prompt variable, since
      // prompt() can only be called once.
      deferredPrompt = null;
    });
  });
}, 500);

We run build_js() and deploy the app to shinyapps.io. Figure 25.8 illustrates the install prompt window that appears to install the app. Once installed, the beforeinstallprompt event does not fire anymore and the app may be launched as a standalone app, for instance on macOSX (Figure 25.9).

Install prompt window.

FIGURE 25.8: Install prompt window.

Installed PWA on macOSX.

FIGURE 25.9: Installed PWA on macOSX.

On Figure 25.9, the blue window color corresponds to the tags$meta(name = "theme-color", content = "#2196f3"), passed in the f7_page layout element. Whenever the connection is lost, the redirection occurs to the offline.html page, as shown on Figure 25.10.

Offline HTML template.

FIGURE 25.10: Offline HTML template.

The final product may be run with:

shinyAppDir(system.file("shinyMobile/pwa", package = "OSUICode"))

25.4 Workbox

Workbox is a more robust alternative to the approach described above. Interestingly, Framework7 uses Workbox as a service worker generator by default. It does:

  • pre-caching of dependencies like CSS and JS but also images and Google fonts to improve performances.
  • Improve offline experience.

This part does not work yet at the time of review submission. I’ll probably remove it …

25.5 Other resources

The process described above works perfectly for any Shiny template. The reader may also consider other packages like {shiny.pwa}, that creates a PWA compatible structure at run time, within the app /www folder.