Rails 5 Progressive Web App

December 13, 2016

PWA

Inspired by some evangelical Progressive Web App (PWA) posts, we wanted to see what PWA goodness we could bring to our Rails 5, Turbolinks 5, Websocket-using web project. So this post is about what steps we have taken thus far.

We are coming at this from a web-first approach, we are following the Basecamp route into mobile with turbolinks-android. Our mobile development effort has mostly gone thus far into product re-design: stripping back and simplifying views to optomise for the more intimate phone format.

Our first basic android app regularly crashed after clicking through about a dozen pages. The logs showed “turbolinks java.lang.OutOfMemoryError: Failed to allocate createBitmap”. So we removed the line of code that screenshots the pages. As a result we don’t get the crashes but we now get the white screen with the loading spinner until the page loads - not the illusion of speed we had hoped for and need to solve.

So what PWA goodness might we bring to our project?

  1. “Installable”, “Discoverable”: User acquisition: less install friction compared to native apps: more routes to market & marketing
  2. “App-like”: Faster initial load for frequently used apps. ( load from local cache ) “Connectivity-independent”: An offline experience
  3. “Re-engageable” User re-engagement with displayed push notifications via sw even when a tab is not open

What are service workers?

Baseline

Before starting, install and run lighthouse to get a baseline. Lighthouse score for signed out home page: 47100

Round 1: add gem ‘serviceworker-rails’

( Objectives: proof of concept, play nicely with the sprockets build process )

  1. To gemfile add: gem 'serviceworker-rails', and run:

    $ rails g serviceworker:install
    
  2. Rearrange app/assets/javascripts files & update app/assets/javascripts/application.js to remove //= require_tree . ( note: serviceworker.js is excluded on purpose!, but includes serviceworker-companion )

  3. To app/assets/javascripts/ add:

    manifest.json.erb;
    serviceworker-companion.js;
    serviceworker.js.erb ( added extra console.log's )
    
  4. To app/views/layouts/_head_meta.html.erb add:

    // app/views/layouts/_head_meta.html.erb
    ...
    <link rel="manifest" href="/manifest.json" />
    <meta name="apple-mobile-web-app-capable" content="yes">
    ...
    
  5. To config/initializers/assets.rb add:

    # config/initializers/assets.rb
    ...
    Rails.configuration.assets.precompile += %w[serviceworker.js manifest.json]
    
  6. Add config/initializers/serviceworker.rb

  7. To app/assets/images/ add:

    ic_launcher-xx-xx.png files
    

Test on localhost, look at Firefox console output:

  • Service worker registered! - always runs afer browser tab refresh
  • Installing! - only runs after service worker has been updated.
  • Activating! - not seen this run lately
  • Fetch - fails to run.

Lighthouse score for signed out home page: 88100

Round 2: use the ‘upup’ version of the service-worker.js

( Objectives: fix Fetch, and more goodness )

  1. Remove app/assets/javascripts/serviceworker-companion.js
  2. To app/assets/javascripts remove: //= require serviceworker-companion
  3. Remove app/assets/javascripts/serviceworker.js.erb ??
  4. Add app/assets/javascripts/upup.js
  5. Add app/assets/javascripts/upup.sw.js
  6. Update config/initializers/assets.rb:

    remove:

    Rails.configuration.assets.precompile += %w[serviceworker.js manifest.json]
    
    

    add:

    Rails.application.config.assets.precompile += %w( bootstrap/glyphicons-halflings-regular.woff2 )
    Rails.configuration.assets.precompile += %w( upup.js upup.sw.js manifest.json )
    
  7. Update config/initializers/serviceworker.rb:

    remove:

    match "/serviceworker.js"
    

    add:

    match "/upup.js"
    match "/upup.sw.js"
    
  8. Add app/views/layouts/_head_service_worker.html.erb ( should be a script file instead?)

    <script src="/upup.js" ></script>
    <script>UpUp.addSettings script ...</script>
    

    Test on localhost, look at Firefox console output:

    • Fetch - now runs. (Hurray!)
  9. Modify upup.sw.js to Fetch assets from cache-else-network-then-add-to-cache; And Fetch non-assets from network-else-cache-else-offline

  10. Add a 418 error page as offline page, and update UpUp.addSettings script accordingly.

    Tested ok on localhost Works ok on production site Lighthouse score for signed out home page: 99100

Round 3: use sw-precache to generate the service-worker.js

https://github.com/GoogleChrome/sw-precache

( Objectives: robust production ready, more goodness)

Google sw-precache is designed to generate scripts during the build process, for example to hash versions of static files - but we dont need this as sprockets does this already, so will use Command-line interface.

  1. To install node on ubuntu

    https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-16-04

    $ sudo apt-get update
    $ sudo apt-get install nodejs
    $ sudo apt-get install npm
    

    If you install from a package manager your bin may be called nodejs so you just need to symlink it like so “sudo ln -s /usr/bin/nodejs /usr/bin/node” -> run:

    $ sudo ln -s /usr/bin/nodejs /usr/bin/node
    
  2. To install sw-precache, run:

    $ sudo npm install --global sw-precache
    
  3. To create a sw, add config file sw-precache-config.js.erb with contents:

    module.exports = {
      dontCacheBustUrlsMatching: /assets/,
      navigateFallback: '/418',
      navigateFallbackWhitelist: [],
      staticFileGlobs: [
        '/assets/**.css',
        '/assets/**.js',
        '/assets/**.json',
        '/assets/**.JPG',
        '/assets/**.png', etc...
      ],
      runtimeCaching: [{
        urlPattern: /./,
        handler: 'networkFirst'
      }],
      swFilePath: 'app/assets/javascripts/service-worker.js'
    };
    
  4. Run sw-precache to write the sw file. run:

    $ sw-precache --config=app/assets/javascripts/sw-precache-config.js --verbose
    
  5. Add app/assets/javascripts/service-worker-registration.js contents: https://github.com/GoogleChrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js#L25

Tested ok on localhost

Works ok on production site, but:

  1. ‘networkFirst’: Every visited page is cached, so can be viewed offline (good). but on reaching a non-visited it bombs with an unbranded, unnavigable offline error page: “This site can’t be reached”. It should show an offline page instead that can be navigated back from;
  2. ‘Add to home screen’: The manifest icons did not come through at first ( dev-assets/ should be assets/ )

Lighthouse score for signed out home page: 99100

Round 4: sw-precache tweeks

( Objectives: add navigable offline feature )

first try To sw-precache-config.js add: navigateFallback to runtimeCaching -> no joy. then…

  1. To sw-precache-config.js

    remove:

    runtimeCaching: [{
      urlPattern: /./,
      handler: 'networkFirst'
      }],
    

    add:

      runtimeCaching: [{
      urlPattern: /ignore/,
      handler: 'networkFirst'
    }],
    importScripts: ['service-worker-addon.js'],
    

    Adding an ignore route causes the sw-toolbox to be included, so the methods can be used in service-worker-addon.js. Note: to force Chrome Firefox to notice an update to the imported script, regenerate the service-worker.sql at the same time.

  2. Add the service-worker-addon.js file with this content:

    console.log("sw addon 1!");
    
    const OFFLINE_URL = '/offline/show';
    toolbox.precache([ OFFLINE_URL ]);
    var networkFirstOffline = function(request, values, options) {
      return toolbox.networkFirst(request, values, options).catch(function(error) {
        console.log("networkFirstOffline error => " + error);
        if (request.method === 'GET' &amp;&amp; request.headers.get('accept').includes('text/html')) {
          return toolbox.cacheOnly(new Request( OFFLINE_URL ), values, options);
        } else { throw error; }
    }) };
    
    toolbox.router.get(/.json$/, toolbox.networkFirst , {"networkTimeoutSeconds": 5} );
    etc...
    toolbox.router.get(/./, networkFirstOffline , {"networkTimeoutSeconds": 5} );
    

It now works ok in production but there will be further tweeks as we discover what it is like to live with…

Next, to add push notifications, see the next blog post Rails 5 Progressive Web App - Part 2

© 2018 Keith P | Follow on Twitter | Git