Yes, Rails Does Support PWAs

Yes, Rails Does Support PWAs

Progressive Web Apps are a constellation of conventions. Those conventions fit neatly into Rails, without the need to introduce a complicated Javascript front end. By embracing core Rails technologies like ActiveJob, ActionCable, Russian Doll Caching, and sprinkles of Stimulus, you can deliver powerful and immersive front end web apps. You can read more here: https://johnbeatty.co/2019/05/06/hnpwa-with-rails-and-stimulus-introduction/

632ca067399ec434c029a134f7c60aa5?s=128

John Beatty

April 30, 2019
Tweet

Transcript

  1. Yes, Rails Does Support PWAs John Beatty – Railsconf 2019

  2. About Me • Started developing iOS, Android, and Blackberry apps

    in 2010 • Learned Rails to build backend APIs and CMSes for these apps
  3. About Me • Started developing iOS, Android, and Blackberry apps

    in 2010 • Learned Rails to build backend APIs and CMSes for these apps • Worked at various startups and independently in the DC area • IT Director and CS Teacher at a small school • Blog at johnbeatty.co
  4. About You! • You’re going to become Rails - PWA

    advocates • No more handwaving! • You’ll be able to argue for a full Rails app instead of a Rails API and a something-something-dot-js front end
  5. About You! • You’re going to become Rails - PWA

    advocates • No more handwaving! • You’ll be able to advocate for a full Rails app instead of a Rails API and a something-something-dot-js front end • Let’s build a version of the Hacker News Progressive Web App • https://hnpwa.com • Spiritual successor to TodoMVC
  6. P What? • Progressive Web Apps are a convention •

    A very simple convention • index.html • manifest.json • service-worker.js
  7. PWA – index.html <!DOCTYPE html> <html lang="en"> <head> <title>The World's

    Fastest PWA!</title> <link rel="manifest" href="/worlds-fastest-pwa/manifest.json"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="theme-color" content="#C50001"!/> <meta name="Description" content="The World's Fastest PWA"> !</head>
  8. PWA – index.html <body> <h1>The World's Fastest PWA!</h1> <p>This is

    the one!!</p> <script type="text/javascript"> if (navigator.serviceWorker) { navigator.serviceWorker.register('/worlds-fastest-pwa/service-worker.js', { scope: '/worlds-fastest-pwa/' }) .then(function(reg) { console.log('[Companion]', 'Service worker registered!'); console.log(reg); }); } !</script> !</body> !</html>
  9. PWA – manifest.json { "short_name": "FAST", "name": "The Worlds Fastest

    PWA", "icons": [ { "src": "/worlds-fastest-pwa/icon_192.png", "type": "image/png", "sizes": "192x192" }, { "src": "/worlds-fastest-pwa/icon_512.png", "type": "image/png", "sizes": "512x512" } ], "start_url": "/worlds-fastest-pwa/", "background_color": "#3367D6", "display": "standalone", "scope": "/worlds-fastest-pwa/", "theme_color": "#C50001" }
  10. PWA – service-worker.js

  11. PWA – service-worker.js var CACHE_VERSION = 'v1'; var CACHE_NAME =

    CACHE_VERSION + ':sw-cache-'; function onInstall(event) { console.log('[Serviceworker]', "Installing!", event); event.waitUntil( caches.open(CACHE_NAME).then(function prefill(cache) { return cache.addAll([ '/worlds-fastest-pwa/offline.html', ]); }) ); }
  12. PWA – service-worker.js function onActivate(event) { console.log('[Serviceworker]', "Activating!", event); event.waitUntil(

    caches.keys().then(function(cacheNames) { return Promise.all( cacheNames.filter(function(cacheName) { !// Return true if you want to remove this cache, !// but remember that caches are shared across !// the whole origin return cacheName.indexOf(CACHE_VERSION) !!!== 0; }).map(function(cacheName) { return caches.delete(cacheName); }) ); }) ); }
  13. PWA – service-worker.js function onFetch(event) { event.respondWith( !// try to

    return untouched request from network first fetch(event.request).catch(function() { !// if it fails, try to return request from the cache return caches.match(event.request).then(function(response) { if (response) { return response; } !// if not found in cache, return default offline content for navigate requests if (event.request.mode !!=== 'navigate' !|| (event.request.method !!=== 'GET' !&& event.request.headers.get('accept').includes('text/html'))) { return caches.match('/worlds-fastest-pwa/offline.html'); } }) }) ); }
  14. PWA – service-worker.js self.addEventListener('install', onInstall); self.addEventListener('activate', onActivate); self.addEventListener('fetch', onFetch);

  15. PWA – resources • Microsoft’s PWA builder • provides a

    quick test of what you can add to an existing site to make it a PWA • https://www.pwabuilder.com • Service Worker Cookbook • https://serviceworke.rs
  16. PWA – resources • Google’s Lighthouse Audit Test • Audit

    your website in Chrome • World’s Fastest PWA • No fancy frameworks • 100/100 on Google’s Lighthouse test • https://github.com/johnbeatty/worlds-fastest-pwa • https://johnbeatty.github.io/worlds-fastest-pwa/
  17. World’s Fastest PWA johnbeatty.github.io/worlds-fastest-pwa

  18. World’s Fastest PWA online offline

  19. Why? • It’s a new convention to solve a common

    problem • Do we need a mobile app? • Restaurant Ordering with push notifications • Store offline information
  20. How • If we want to match the perceived performance

    of a mobile app • We need to make things feel fast • Let’s use a bunch of existing rails conventions!
  21. How • Stimulus • ActionCable • ActiveJob • Russian Doll

    Caching • HTML everywhere
  22. HNPWA • Let’s build a version of the Hacker News

    Progressive Web App • https://hnpwa.com • Spiritual successor to TodoMVC • https://github.com/johnbeatty/hnpwa-app
  23. Don’t Block The Main Thread • In iOS development, we

    had a rule “Don’t block the main thread” • Web apps need to adopt this rule too • Load the bare minimum, and then let data load in from background
  24. Don’t Block The Main Thread • Turbolinks helps by loading

    the CSS and Javascript only once • ActionCable loads data as it’s available, so the request can send the skeleton HTML first • Any background tasks, such as calls to third party APIs, can be put into ActiveJob • The service worker caches files locally
  25. The HNPWA USING RAILS AS THE FRONT END

  26. HNPWA • Attempting to highlight a fast, reliable, and engaging

    experience • https://hnpwa.com • https://github.com/tastejs/hacker-news-pwas
  27. HNPWA • A spiritual successor to TODOMVC • Trying to

    compare different tech stacks • Can differ on based on server infrastructure and performance patterns
  28. The HNPWA built with Rails

  29. HNPWA It scored 100s on Google’s Lighthouse test

  30. HNPWA Emerging Markets Mobile Test

  31. Hacker News API • JSON API provides all the data

    • https://github.com/HackerNews/API • 5 endpoints are used to pull Item ids from Hacker News • An Item endpoint provides all the information for each id
  32. Hacker News API • Item • Contain all the displayable

    information we care about • User • Contains username and bio
  33. Modeling The Hacker News API Item TopItem NewItem ShowItem AskItem

    JobItem Each parent item refers to an Item, since an Item could be from Top, New, Show, or Ask at the same time
  34. Modeling The Hacker News API Item TopItem Each parent item

    records its order as received from the API so that it can match how it appears on Hacker News Item TopItem Item TopItem Location 0 Location 1 Location 2
  35. Modeling The Hacker News API Item Comments are represented as

    children items Item Item Item Item
  36. Fetching The Hacker News API • There are five end

    points like this: • /top loads /v0/topstories in LoadTopItemsJob • /new loads /v0/newstories in LoadNewItemsJob • /ask loads /v0/askstories in LoadAskItemsJob • /show loads /v0/showstories in LoadShowItemsJob • /job loads /v0/jobstories in LoadJobItemsJob • Each Job is scheduled by the whenever gem
  37. Fetching The Hacker News API • The /top page could

    visit the APIs /v0/topstories endpoint and gets back up 500 different ids • Then it needs to make 500 requests to /v0/item/<id> for each id • Even when paginated, this is a lot of API traffic • If it’s put into a job that runs on a regular interval, the app won’t block the “main thread”
  38. Fetching The Hacker News API class LoadTopItemsJob < ApplicationJob queue_as

    :default def perform(*args) top_stories_json = JSON.parse Http.get("https:!//hacker-news.firebaseio.com/v0/ topstories.json").to_s top_stories_json.each_with_index do |hn_story_id, top_news_location| LoadTopItemJob.perform_later top_news_location, hn_story_id end TopItem.where("location !>= ?", top_stories_json.length).destroy_all end end app/jobs/load_top_items_job.rb
  39. Fetching The Hacker News API • Each of these id

    fetching jobs spawns more individual job • LoadTopItemJob fetches the details for an Item, stores the results, and then passes the information to anyone listening for that item on the front end over an ActionCable connection • Caches the Item into an HTML partial • Repeat for LoadNewItemJob, LoadShowItemJob, LoadAskItemJob, and LoadJobItemJob
  40. Fetching The Hacker News API class LoadTopItemJob < ApplicationJob queue_as

    :default def perform(top_news_location, hn_story_id) story_json = JSON.parse Http.get("https:!//hacker-news.firebaseio.com/v0/item/ !#{hn_story_id}.json?print=pretty").to_s # populate Item and TopItem model from story_json ActionCable.server.broadcast "TopItemChannel:!#{top_item.location}", { message: TopsController.render( top_item.item ).squish, location: top_item.location } ActionCable.server.broadcast "ItemsListChannel:!#{top_item.item.id}", { item: ItemsController.render( top_item.item ).squish, item_id: top_item.item.id } end end app/jobs/load_top_item_job.rb
  41. Displaying The Hacker News API • Everything is rendered server

    side in HTML • Using Russian Doll Caching to deliver HTML really quickly, despite N+1 stucture
  42. Displaying The Hacker News API class TopsController < ApplicationController def

    show @page = params[:page] ? params[:page].to_i : FIRST_PAGE @total_pages = TopItem.count / ITEMS_PER_PAGE @top_item = TopItem.order(:updated_at).last @top_items = TopItem.order(:location).limit(ITEMS_PER_PAGE).offset(@page * ITEMS_PER_PAGE).includes(:item) end end app/controllers/tops_controller.rb
  43. Displaying The Hacker News API <%= cache ['news-list', @top_item, @page]

    do %> <section class="section" data-controller="item-location items" data-item-location-channel="TopItemChannel"> <div class="columns is-multiline"> <% @top_items.each_slice(4) do |slice| %> <% slice.each do |top_item| %> <%= render top_item %> <% end %> <% end %> !</div> !</section> <% end %> app/views/tops/show.html.erb
  44. Displaying The Hacker News API <%= cache top_item do %>

    <div class="column" data-location="<%= top_item.location %>"> <%= render top_item.item %> !</div> <% end %> app/views/top_items/_top_item.html.erb
  45. Displaying The Hacker News API <%= cache item do %>

    <div class="card" data-item-id="<%= item.id %>"> <header class="card-header"> !!!<!-- Item header HTML !!--> !</header> <div class="card-content"> !!!<!-- Item content HTML !!--> !</div> <div class="card-footer"> !!!<!-- Item footer HTML !!--> !</div> !</div> <% end %> app/views/items/_item.html.erb
  46. Displaying The Hacker News API • By aggressively caching the

    content as it’s received, and using the Russian doll caching, page loads can feel instantaneous • It feels like the app isn’t blocking the main thread • This caching technique is used on the other pages
  47. Refreshing The Hacker News API • What happens as the

    background jobs pull in new information? • Send it over a websocket!
  48. Refreshing The Hacker News API • Each page uses listens

    for new items • Stimulus manages subscribing and closing the ActionCable subscriptions as a reader navigates through the site.
  49. Refreshing The Hacker News API • There are two different

    channels on the /top page, • ItemsListChannel: Listen for updates to an individual item • TopItemChannel: Listen for updates to a location change • Asynchronous updates make the web app feel performant
  50. Subscribing To ActionCable Channels • Stimulus Controllers connect to ActionCable

    to listen to changes to items.
  51. Subscribing To ActionCable Channels <section class="section" data-controller="item-location items" data-item-location-channel="TopItemChannel"> app/views/tops/show.html.erb

    <div class="column" data-location="<%= top_item.location %>"> <%= render top_item.item %> !</div> app/views/top_items/_top_item.html.erb
  52. Subscribing To ActionCable Channels app/javascript/controllers/item_location_controller.js import { Controller } from

    "stimulus" import createChannel from "cables/cable"; export default class extends Controller { initialize() { let thisController = this; this.channel = createChannel( this.data.get("channel"), { connected() { thisController.listen() }, received({ message, location }) { let existingItem = document.querySelector(`[data-location='${ location }']`) if (existingItem) { existingItem.innerHTML = message } } }); } }
  53. Subscribing To ActionCable Channels app/javascript/controllers/item_location_controller.js connect() { this.listen() } disconnect()

    { if (this.channel) { this.channel.perform('unfollow') } } listen() { if (this.channel) { let locations = [] for (const value of document.querySelectorAll(`[data-location]`)) { locations.push( value.getAttribute('data-location') ) } this.channel.perform('follow', { locations: locations } ) } } }
  54. Subscribing To ActionCable Channels app/channels/top_item_channel.rb class TopItemChannel < ApplicationCable!::Channel def

    follow(data) stop_all_streams locations = data['locations'] unless locations.nil? locations.each do |location| stream_from "TopItemChannel:!#{location}" end end end def unfollow stop_all_streams end end
  55. PWA The Hacker News API • How do we make

    this really fast web app a PWA now? • Where do the PWA conventions fit into a Rails app?
  56. PWA The Hacker News API Rails.application.routes.draw do get '/service-worker.js' !=>

    "service_worker#service_worker" get '/manifest.json' !=> "service_worker#manifest" get '/offline.html' !=> "service_worker#offline" end config/routes.rb
  57. PWA The Hacker News API class ServiceWorkerController < ApplicationController protect_from_forgery

    except: :service_worker def service_worker end def manifest end def offline end end app/controllers/service_worker_controller.rb
  58. { "short_name": "HNWPA", "name": "Hacker News Progressive Web App", "icons":

    [ { "src": "<%= asset_path('train_192.png') %>", "type": "image/png", "sizes": "192x192" }, { "src": "<%= asset_path('train_512.png') %>", "type": "image/png", "sizes": "512x512" } ], "start_url": "<%= top_path %>", "background_color": "#f2f3f5", "display": "standalone", "scope": "<%= root_path %>", "theme_color": "#f60" } app/views/service_worker/manifest.json.erb
  59. var CACHE_VERSION = 'v1'; var CACHE_NAME = CACHE_VERSION + ':sw-cache-';

    function onInstall(event) { console.log('[Serviceworker]', "Installing!", event); event.waitUntil( caches.open(CACHE_NAME).then(function prefill(cache) { return cache.addAll([ '<%= asset_pack_path 'application.js' %>', '<%= asset_pack_path 'application.css' %>', '/offline.html', ]); }) ); } app/views/service_worker/service-worker.js.erb
  60. PWAs Are Easy • We just described a fast web

    app that is is built entirely on Rails defaults • Turbolinks let’s the app only send HTML after the first load • Russian Doll Caching speeds up each request • ActiveJob keeps unnecessary work out of the request response cycle • ActionCable loads data as its ready • Stimulus adds the sprinkles of interactivity
  61. PWAs Are Easy • We just described a fast web

    app that is is built entirely on Rails defaults • Turbolinks let’s the app only send HTML after the first load • Russian Doll Caching speeds up each request • ActiveJob keeps unnecessary work out of the request response cycle • ActionCable loads data as its ready • Stimulus adds the sprinkles of interactivity
  62. You’re a PWA Native • Speed up your web app

    by not blocking the “main thread” • PWAs can now replace android apps • This might be something to consider in light of Turbolinks’ Native Android wrapper in the midst of a rewrite • You’re now able to cut through the front end fog, and keep it Rails.
  63. Thank you! @jpbeatty https://johnbeatty.co