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. 2.

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

    in 2010 • Learned Rails to build backend APIs and CMSes for these apps
  2. 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
  3. 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
  4. 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
  5. 6.

    P What? • Progressive Web Apps are a convention •

    A very simple convention • index.html • manifest.json • service-worker.js
  6. 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>
  7. 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>
  8. 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" }
  9. 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', ]); }) ); }
  10. 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); }) ); }) ); }
  11. 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'); } }) }) ); }
  12. 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
  13. 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/
  14. 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
  15. 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!
  16. 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
  17. 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
  18. 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
  19. 26.

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

    experience • https://hnpwa.com • https://github.com/tastejs/hacker-news-pwas
  20. 27.

    HNPWA • A spiritual successor to TODOMVC • Trying to

    compare different tech stacks • Can differ on based on server infrastructure and performance patterns
  21. 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
  22. 32.

    Hacker News API • Item • Contain all the displayable

    information we care about • User • Contains username and bio
  23. 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
  24. 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
  25. 35.
  26. 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
  27. 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”
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. 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
  35. 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
  36. 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
  37. 47.

    Refreshing The Hacker News API • What happens as the

    background jobs pull in new information? • Send it over a websocket!
  38. 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.
  39. 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
  40. 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
  41. 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 } } }); } }
  42. 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 } ) } } }
  43. 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
  44. 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?
  45. 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
  46. 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
  47. 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
  48. 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
  49. 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
  50. 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
  51. 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.