http://triskweline.de/unpoly-rugb/#/ It's not you, it's us We're breaking up with JavaScript frontends Henning Koch, makandra GmbH @triskweline Give it 10 minutes. Context * makandra is a Ruby on Rails consultancy * We start a new application every 3 months * We maintain apps for a really long time + 50+ apps in maintenance + Oldest app is from 2007 * Will we be able to do this for another 10 years? Tweet from 2025 [future_twe] Complexity Server Client Based on chart by @ryanstout Complexity in 2005 Authorization Routing Models Controllers Views Dependencies Server Client Based on chart by @ryanstout Complexity in 2008 Authorization Routing Models Controllers Views Dependencies Server RandomJS Dependencies Client Based on chart by @ryanstout Complexity in 2009 API Authorization Routing Models Controllers Views Dependencies Server RandomJS Dependencies Client Based on chart by @ryanstout Complexity in 2011 Asset packing API Authorization Routing Models Controllers Views Dependencies Server RandomJS Dependencies Client Based on chart by @ryanstout Complexity in 2013 Asset packing API Authorization Routing Models Controllers Views Dependencies Server Models / API client Controllers Views Dependencies Client Based on chart by @ryanstout Complexity in 2014 Asset packing API Authorization Routing Models Controllers Views Dependencies Server Authorization Routing Models / API client Controllers Views Dependencies Client Based on chart by @ryanstout Complexity in 2015 Prerendering Asset packing API Authorization Routing Models Controllers Views Dependencies Server Virtual DOM Authorization Routing Models / API client Controllers Views Dependencies Client Based on chart by @ryanstout A look back at the 7 AngularJS projects that we wrote from 2013-2016: 1 2 3 4 5 6 7 In hindsight, these are the projects that should have been a SPA: 1 2 3 4 5 6 7 YMMV. Learnings from 3 years of SPAs 1. SPAs are a good fit for a certain class of UI. For us that class is the exception, not the default. 2. There's a trade-off between UI fidelity and code complexity. 3. We think we can fix most of the problems with server-side apps and find a new sweet spot in that trade-off. [trade_off] What server-side apps do well 1. Wide choice of great and mature languages 2. Low complexity stack 3. Synchronous data access 4. Time to first render 5. Works on low-end devices What server-side apps don't do well 1. Slow interaction feedback 2. Page loads destroy transient state (scroll positions, unsaved form values, focus) 3. Layered interactions are hard (modals, drop-downs, drawers) 4. Animation is complicated 5. Complex forms Demo of server-side app issues Link to demo app (press Start Classic on first page) Things to try and observe: * Navigate between cards in the left pane Scroll positions get lost in both panes * Open the second page ("More cards" link at the bottom of the left pane) Card in the right pane gets lost * Edit a card, change the title, then change the pattern Unsaved form content is gone when returning from the pattern selection How to fix server-side apps? A thought experiment [html6] Imagine HTML6 was all about server-side apps What features would be in that spec? Partial page updates? Animated transitions? Layered interactions? We can polyfill all of that! Because it's 2016 and JavaScript is now fast. [unpoly] * 25 new HTML attributes to write modern UI, but keep logic on the server * Works with existing code little to no changes required on the server side * Works with any backend language or framework although we have some nice Rails bindings * In development for two years and in production with multiple apps Demo of Unpoly-enhanced app Link to demo app (press Start Enhanced on first page) Things to try and observe: 1. Navigate between cards, open and cancel the form Page transitions are animated 2. Navigate between cards in the left pane Scroll positions are kept in both panes 3. Open the second page ("More cards" link at the bottom of the left pane) New page slides in smoothly Card in the right pane is kept 4. Edit a card, change the title, then change the pattern Pattern selection happens in a modal dialog, preserving unsaved form values 5. Inspect links and see attributes with up-* prefix See docs for [up-target] and [up-modal] Classic page flow [fragment_flow_vanilla] Server renders full pages on every request. Any state that's not in the URL gets lost when switching pages. Unpoly page flow [fragment_flow_unpoly] Server still renders full pages, but we only use fragments. This solves most of our problems with transient state being destroyed. Layers Document http://app/list Modal http://app/new Popup http://app/search Unpoly apps may stack up to three HTML pages on top of each other Each layer has its own URL and can navigate without changing the others Use this to keep context during interactions [gmail] [gmail_laye] Layers Replace fragment Open fragment in dialog Open fragment in dropdown Links in a layer prefer to update fragments within the layer Changing a fragment behind the layer will close the layer Navigation * All fragment updates change the browser URL by default. * Back/Forward buttons work as expected. Even scroll positions are restored. * Linking to a fragment will scroll the viewport to reveal the fragment. Unpoly is aware of fixed navigation bars and will scroll further/ less. * Links to the current URL get an .up-current class automatically. But I have this really custom JavaScript / jQuery library / behavior that I need to integrate Don't worry, we actually allow for massive customization: * Pairing JavaScript snippets with HTML elements * Integrating libraries (WYSIWYG editor, jQuery plugins, ...) * Passing structured data to JavaScript snippets * Reuse existing Unpoly functionality from your own code * Invent your own UJS syntax * Configure defaults Activating JS snippets Every app needs a way to pair JavaScript snippets with certain HTML elements: * A textarea.wysiwyg should activate Redactor on load * An input[type=search] field should automatically request new results when the user types something * A button.toggle-all should toggle all checkboxes when clicked * A .map should render a map via the Google Maps JS API Activating JS snippets Random.js
document.addEventListener('DOMContentLoaded', function(event) { document.querySelectorAll('.map').forEach(function(element) { new google.maps.Map(element) }) }) This is what you see in JavaScript tutorials. HTML fragments loaded via AJAX don't get Javascriptified. Activating JS snippets Unpoly
up.compiler('.map', function(element) { new google.maps.Map(element) }) Unpoly automatically compiles all fragments that it inserts or updates. On the first page load, the entire document is compiled. Getting data into your JS Random.js
function initMap(element, center) { new google.maps.Map(element, { center: center }) }) Getting data into your JS Unpoly
up.compiler('.map', function(element, center) { new google.maps.Map(element, center: center) }) The [up-data] attribute value is parsed as JSON and passed to your compiler as a JavaScript object. Symmetry to CSS components If you are using some CSS component architecture like BEM you will find a nice symmetry between your CSS components and Unpoly compilers: app/assets/stylesheets/blocks carousel.css head.css map.css screenshot.css tail.css app/assets/javascripts/compilers carousel.js head.js map.js screenshot.js By sticking to this pattern you will always know where to find the CSS / JS that affects your
...
element. Response times Reponse times How fast are SPAs? We want to approximate the snappiness of a AngularJS SPA (since we're happy with those). How fast is an SPA? * Most of our AngularJS interactions are making API requests and are thus bound by server speed. * Rendering to JSON takes time, too. 60-300ms for index views in non-trivial app * Client-side rendering takes time, too. * Users do like the instantaneous feedback (even if it just shows to an empty screen that is then populated over the wire) Response times Unpoly's approach * Provide instantaneous feedback to all user input so interactions appear faster than they really are * Pick all the low-hanging fruit that's wasting 100s of milliseconds * Free up enough time budget that we can afford to render full pages on the server * Use best practices for server performance * Provide options if all that is not enough What you get out of the box * We no longer parse and execute CSS, JavaScript and build the DOM on every request makandra deck on Cards (140 ms), AMC frontend (360 ms) * Clicked links/forms get an .up-active class while loading Get into a habit of styling .up-active for instantaneous feedback Use throttling and Chrome's network tab * Links with an [up-instant] attribute load on mousedown instead of click Saves ~70 ms with a mouse (test yourself) Saves ~300 ms on unoptimized sites with touch device Your Linux/Windows apps do that, too! * Links with [up-preload] attribute preload destination while hovering Saves ~200-400 ms minus configured delay (test yourself) * Responses to GET requests are cached for 5 minutes Any non-GET request clears the entire cache Feel the response time of an Unpoly app by navigating between cards on makandracards.com/makandra. Paste this into the console to visualize mousedown events: function showEvent() { var $div = $('
mousedown!
'); $div.css({ backgroundColor: 'blue', color: 'white', fontSize: '20px', padding: '20px', position: 'fixed', left: '0', top: '0', zIndex: '99999999' }); $div.appendTo(document.body); $div.fadeOut(500, function() { $div.remove() }); }; document.addEventListener('mousedown', showEvent, { capture: true }); How you can optimize further * Server-side fragment caching * Tailor responses for the requested selector * Spinners for long-running requests * We can still implement client-side interactions * Go nuclear with two-way bindings Tailor responses for the requested selector <% if up.target?('.side') %>
...
<% end %> <% if up.target?('.main') %>
...
<% end %> up.target?(css) looks at the X-Up-Target HTTP header that Unpoly sends with every request. Spinners For the occasional long-running request, you can configure this globally:
Please wait!
up.compiler('.spinner', function(element) { function show() { element.style.display = 'block' } function hide() { element.style.display = 'none' } up.on('up:proxy:slow', show) up.on('up:proxy:recover', hide) hide() }); The up:proxy:slow event is triggered after 300 ms (configurable). We can still implement interactions on the client
Hello !
up.compiler('.greeter', function(element) { let input = element.querySelector('.greeter--input') let name = element.querySelector('.greeter--name') input.addEventListener('input', function() { name.textContent = input.value }) }) Going nuclear Two-way bindings With Rivets.js (6 KB):
Hello { name }!
up.compiler('.template', function(element, data) { let view = rivets.bind(element, data) return function() { view.unbind } // clean up }) Composability Homegrown UJS syntax usually lacks composability. Changing that was a major design goal for Unpoly. Composability JavaScript API Unpoly's default UJS behavior is a small layer around a JS API. You can use this JS API to call Unpoly from your own code: Unobtrusive Continue Programmatic up.replace('.story', 'full.html') Composability Events $(document).on('up:modal:open', function(event) { if (dontLikeModals()) { event.preventDefault() } }) Composability Invent your own UJS syntax HTML Show more JavaScript up.compiler('[menu-link]', function(element) { element.addEventListener('click', function(event) { event.preventDefault(); up.popup.attach(element, { target: '.menu', position: 'bottom-left', animation: 'roll-down' }); }); }); The JavaScript API is extensive View full documentation of JS functions, events and UJS selectors on unpoly.com. Animation When a new element enters the DOM, you can animate the appearance: Open settings When you swap an element, you can transition between the old and new states: Show users Animations are implemented via CSS transforms on a 3D-accelerated layer. Forms Painful things with forms: * Submitting a form via AJAX * File uploads via AJAX * Detecting redirects of an AJAX form submission * Dealing with validation errors of an AJAX form submission * Server-side validations without a page load * Dependencies between fields * Submitting a form within a modal while keeping the modal open These are all solved by Unpoly. Ajax forms A form with [up-target] will be submitted via AJAX and leave surrounding elements intact:
...
A successful submission (status 200) will update .main A failed submission (non-200 status) will update the form itself (Or use an [up-fail-target] attribute) Return non-200 status when validations fail class UsersController < ApplicationController def create user_params = params[:user].permit(:email, :password) @user = User.new(user_params) if @user.save? sign_in @user else render 'form', status: :bad_request end end end Forms within a modal To stay within the modal, target a selector within the modal:
...
To close the modal, target a selector behind the modal:
...
Server-side validations without a page load
Server-side validations without a page load
Server-side validations without a page load
Server-side validations without a page load class UsersController < ApplicationController def create @user = User.new(user_params) if @user.save sign_in @user else render 'form', status: :bad_request end end end Server-side validations without a page load class UsersController < ApplicationController def create @user = User.new(user_params) if up.validate? @user.valid? # run validations, but don't save to DB render 'form' # render form with error messages elsif @user.save? sign_in @user else render 'form', status: :bad_request end end end Dependent fields Simple cases Use [up-switch] to show or hide existing elements based on a control value:
only shown for advancedness = basic
hidden for advancedness = basic
Dependent fields Harder cases Use [up-validate] to update a field from the server when a control value changes. In the example below we load a list of employees when the user selects a department:
What server-side apps don't do well 1. Slow interaction feedback 2. Page loads destroy transient state (scroll positions, unsaved form values, focus) 3. Layered interactions are hard (modals, drop-downs, drawers) 4. Animation is complicated 5. Complex forms What server-side apps can actually be okay at 1. Slow interaction feedback 2. Page loads destroy transient state (scroll positions, unsaved form values, focus) 3. Layered interactions are hard (modals, drop-downs, drawers) 4. Animation is complicated 5. Complex forms [trade_off] Getting started * Check out unpoly.com to get an overview of what's included * npm install unpoly --save or gem 'unpoly-rails' (other methods) * Replace half your JavaScript with [up-target] attributes * Convert remaining JavaScripts into up.compiler() [unpoly] henning.koch@makandra.de @triskweline Additional slides Update a page fragment without JavaScript Run example on unpoly.com short.html

Story summary

Read full story

This text won't change

full.html

Full story

Lorem ipsum dolor sit amet.

Read summary
* Unpoly requests full.html via AJAX * Server renders a full HTML page * Unpoly extracts the fragment that matches .story * Unpoly swaps the old and new fragment * Unpoly changes the browser URL to http://host/full.html Open fragment in modal dialog without JavaScript Run example on unpoly.com

Story summary

Read full story
* Unpoly requests full.html via AJAX * Server renders a full HTML page * Unpoly extracts the fragment that matches .story * Unpoly displays the new fragment in a
* Unpoly changes the browser URL to http://host/full.html Open fragment in a popup menu without JavaScript Run example on unpoly.com

Story summary

Read full story
* Unpoly requests full.html via AJAX * Server renders a full HTML page * Unpoly extracts the fragment that matches .story * Unpoly displays the new fragment in a
* Unpoly changes the browser URL to http://host/full.html All Async actions return promises up.replace('.story', 'full.html').then(function() { // Fragments were loaded and swapped }); up.morph('.old', '.new', 'cross-fade').then(function() { // Transition has completed }); Curriculum lesson on promises Appending instead of replacing
  • Wash car
  • Purchase supplies
  • Fix tent
  • Next page This appends the second page to the task list and replaces the "Next page" button with a link to page 3. Persisting elements

    Story summary

    Read full story
    Updating persisted elements
    Lat: Lng:
    up.compiler('.map', function(element, pins) { var map = new google.maps.Map(element) pins.forEach(function(pin) { var position = new google.maps.LatLng(pin.lat, pin.lng); new google.maps.Marker({ position: position, map: map }) })
    Lat: Lng:
    up.compiler('.map', function(element, pins) { var map = new google.maps.Map(element); pins.forEach(function(pin) { var position = new google.maps.LatLng(pin.lat, pin.lng) new google.maps.Marker({ position: position, map: map }) })
    <%= form_for Pin.new, html: { 'up-target' => '.map' } do |form| %> Lat: <%= form.text_field :lat %> Lng: <%= form.text_field :lng %> <%= form.submit 'Add pin' %> <% end %> up.compiler('.map', function(element, pins) { var map = new google.maps.Map(element) pins.forEach(function(pin) { var position = new google.maps.LatLng(pin.lat, pin.lng) new google.maps.Marker({ position: position, map: map }) })
    <%= form_for Pin.new, html: { 'up-target' => '.map' } do |form| %> Lat: <%= form.text_field :lat %> Lng: <%= form.text_field :lng %> <%= form.submit 'Add pin' %> <% end %> up.compiler('.map', function(element, initialPins) { var map = new google.maps.Map(element) function renderPins(pins) { ... } renderPins(initialPins) });
    <%= form_for Pin.new, html: { 'up-target' => '.map' } do |form| %> Lat: <%= form.text_field :lat %> Lng: <%= form.text_field :lng %> <%= form.submit 'Add pin' %> <% end %> up.compiler('.map', function($element, initialPins) { var map = new google.maps.Map($element); function renderPins(pins) { ... } renderPins(initialPins) element.addEventListener('up:fragment:keep', function(event) { renderPins(event.newData) }) }) Find-as-you-type search
    <% @users.each do |user| %> <%= link_to user.email, user > <% end %>
    Memory leaks * A regular Random.js app is full of memory leaks. * We just don't notice because the JavaScript VM is reset between page loads! * We can't have memory leaks in persistent JavaScript VMs like Angular.js, Unpoly, Turbolinks * Background: one, two. Random.js HTML JavaScript $.unobtrusive(function() { $(this).find('clock', function() { var $clock = $(this); function updateClock() { var now = new Date(); $clock.html(now.toString()); } setInterval(updateClock, 1000); }); }); Random.js: Leaky HTML JavaScript $.unobtrusive(function() { $(this).find('clock', function() { var $clock = $(this); function updateClock() { var now = new Date(); $clock.html(now.toString()); } setInterval(updateClock, 1000); // creates one interval per ! }); }); Unpoly compiler: Still leaky HTML JavaScript up.compiler('clock', function(clock) { function updateClock() { var now = new Date() clock.textContent = now.toString() } setInterval(updateClock, 1000) // this still leaks memory! }); Unpoly compiler: Clean HTML JavaScript up.compiler('clock', function(clock) { function updateClock() { var now = new Date() clock.textContent = now.toString() } var interval = setInterval(updateClock, 1000) return function() { clearInterval(interval) } // clean up when destroyed }) Unpoly compiler: Leaky up.compiler('textarea.wysiwyg', function(textarea) { $R(textarea) }) Unpoly compiler: Clean up.compiler('textarea.wysiwyg', function(textarea) { $R(textarea) return function() { $R(textarea, 'destroy') } }) Why transitions are hard [transition_desired] Why transitions are hard [transition_naive] Ghosting Old position: static display: hidden New position: static opacity: 0 Old (ghost) position: absolute New (ghost) position: absolute Without ghosting [transition_naive] With ghosting [transition_desired] Predefined animations fade-in fade-out move-to-top move-from-bottom move-to-bottom move-from-top move-to-left move-from-right move-to-right move-from-left Predefined transitions cross-fade move-top move-bottom move-left move-right Custom animations up.animation('zoom-in', function(element, options) { var firstFrame = { opacity: 0, transform: 'scale(0.5)' } var lastFrame = { opacity: 1, transform: 'scale(1)' } up.element.setStyle(element, firstFrame) return up.animate(element, lastFrame, options) }) Toggle all: On load Toggle all document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('.toggle-all').forEach(function(element) { element.addEventListener('click', function() { let form = element.closest('form'); let checkboxes = form.querySelectorAll('input[type=checkox]'); let someUnchecked = !!checkboxes.find(function(cb) { !cb.checked } checkboxes.forEach(function(cb) { cb.checked = someUnchecked }) }) }) }) Run example on codepen.io. This is what you see in jQuery tutorials. HTML fragments loaded via AJAX don't get Javascriptified. Toggle all: Angular directive Toggle all app.directive('toggle-all', function() { return { restrict: 'C', link: function(scope, $link) { $link.on('click', function() { var $form = $link.closest('form') var $checkboxes = $form.find(':checkbox') var someUnchecked = $checkboxes.is(':not(:checked)') $checkboxes.prop('checked', someUnchecked) }) } } }) It's nice how Angular lets us register a compiling function for a CSS selector. Also we don't need to manually Javascriptify new fragments as long as we insert them through Angular templates Toggle all: Unpoly compiler Toggle all up.compiler('.toggle-all', function(element) { element.addEventListener('click', function() { let form = element.closest('form'); let checkboxes = form.querySelectorAll('input[type=checkox]'); let someUnchecked = !!checkboxes.find(function(cb) { !cb.checked } checkboxes.forEach(function(cb) { cb.checked = someUnchecked }) }) }) Unpoly automatically compiles all fragments that it inserts or updates. Legacy browsers Unpoly gracefully degrades with old versions of Internet Explorer: Edge Full support IE 10 Full support Page updates that change browser history IE 9 fall back to a classic page load. Animations and transitions are skipped. IE8 Unpoly prevents itself from booting, leaving you with a classic server-side application Bootstrap integration * Include unpoly-bootstrap3.js and unpoly-bootstrap3.css * Configures Unpoly to use Bootstrap styles for dialogs, marking current navigation tabs, etc. * Makes Unpoly aware of fixed Bootstrap layout components when scrolling the viewport. * In general we try to not do things that would totally clash with Bootstrap. Rails integration Include unpoly-rails In your Gemfile: gem 'unpoly-rails' In your controllers, views and helpers: up? # request.headers['X-Up-Target'].present? up.target # request.headers['X-Up-Target'] up.title = 'Title from server' # response.headers['X-Up-Title'] = 'Title ...' up.validate? # request.headers['X-Up-Validate'].present? The gem also provides the JS and CSS assets for the latest Unpoly. Other installation methods * Install with Bower * Link to a CDN * Download as ZIP * Clone with Git Although the Rails bindings are nice, Unpoly works with any kind of backend. E.g. unpoly.com is a static middleman site using Unpoly. Unit testing Use Jasmine to describe examples. Use jasmine-jquery to create sample elements. Use up.hello to compile sample elements. up.compiler('.current-year', function($element) { var year = new Date().getFullYear(); $element.text(year); }); describe('.current-year', function() { it("displays today's year", function() { $element = affix('.current-today'); up.hello($element); year = new Date().getFullYear(); expect($element).toHaveText(year.toString()); }); }); Easier integration testing Disable animation: up.motion.config.enabled = false; Disable concurrent requests: up.proxy.config.maxRequests = 1; Wait before you do things: AfterStep do sleep 0.05 while page.evaluate_script('window.up && up.proxy.isBusy()') end (Or use patiently). Use jasmine-ajax to mock the network: up.compiler('.server-time', function($element) { $element.text('Loading ...'); up.ajax('/time').then(function(time) { $element.text(time) }; }); describe('.current-year', function() { it('fetches and displays the current time from the server', function() { jasmine.Ajax.install(); var $element = affix('.server-time'); up.hello($element); expect($element).toHaveText('Loading...'); jasmine.Ajax.requests.mostRecent().respondWith({ status: 200, contentType: 'text/plain', responseText: '13:37:00' }); expect($element).toHaveText('13:37:00'); }); }); Who else went back? * Shopify * Formkeep by Thoughtbot * Honeybadger * Betterment Project state * In development since October 2014 * ~ 500 specs (how many specs has our Random.js?) * Has seen some real world pain, but we're still learning new things * Changelog lists breaking changes and compatible changes separately * API marks features as either stable or experimental. * There will be breaking changes, but always an upgrade path Response times * 0.1 second is about the limit for having the user feel that the system is reacting instantaneously, meaning that no special feedback is necessary except to display the result. * 1.0 second is about the limit for the user's flow of thought to stay uninterrupted, even though the user will notice the delay. Normally, no special feedback is necessary during delays of more than 0.1 but less than 1.0 second, but the user does lose the feeling of operating directly on the data. * 10 seconds is about the limit for keeping the user's attention focused on the dialogue. For longer delays, users will want to perform other tasks while waiting for the computer to finish, so they should be given feedback indicating when the computer expects to be done. Feedback during the delay is especially important if the response time is likely to be highly variable, since users will then not know what to expect. Miller 1968; Card et al. 1991; Jacob Nielsen 1993 Also see Google's RAIL Performance Model. Repurpose existing UJS syntax HTML Show more JavaScript up.macro('[menu-link]', function($link) { $link.attr( 'up-target': '.menu', 'up-position': 'bottom-left', 'up-animation': 'roll-down' }); }); Is Unpoly right for my project? You are not writing super ambitious UI You have some control over the UI requirements You're ready to launch 100% of your JavaScript from up.compiler You're OK with dealing with the occasional breaking change Is your alternative home-growing an informal Random.js framework?