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

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.

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

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.

  • 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

Server renders full pages on every request.
Any state that's not in the URL gets lost when switching pages.

Unpoly page flow

Server still renders full pages, but we only use fragments.
This solves most of our problems with transient state being destroyed.

Layers

Document
https://app/list
Modal
https://app/new
Popup
https://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

Layers

<a href="/list" up-target=".main">Replace fragment</a>

<a href="/new" up-modal=".main">Open fragment in dialog</a>

<a href="/menu" up-popup=".main">Open fragment in dropdown</a>

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

<div class="map"></span>




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

<div class="map"></span>




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

<div class="map"></div>

<script type="text/javascript">
  initMap(document.querySelector('.map'), { lat: 48.75, lng: 11.45 })
</script>
function initMap(element, center) {
  new google.maps.Map(element, { center: center })
})


 
 

Getting data into your JS
Unpoly

<div class="map" up-data="{ lat: 48.75, lng: 11.45 }"</div>
 
 
 
 
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 <div class='map'>...</div> 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 = $('<div>mousedown!</div>');
  $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

<html>
  <body>

    <% if up.target?('.side') %>
      <div class='side'>
        ...
      </div>
    <% end %>

    <% if up.target?('.main') %>
      <div class='main'>
        ...
      </div>
    <% end %>

  </body>
</html>

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:

<div class="spinner">Please wait!</div>
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

<div class='greeter'>
  <input class='greeter--input'>
  Hello <span class='greeter--name'><span>!
</div>
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):

<div class='template' up-data='{ "name": "Arne" }'>
  <input rv-value='name'>
  Hello { name }!
</div>
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

<a href="full.html" up-target=".story">Continue</a>

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

<a menu-link href="/details">Show more</span>

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:

<a href="/settings" up-modal=".menu" up-animation="fade-in">
  Open settings
</a>

When you swap an element, you can transition between the old and new states:

<a href="/users" up-target=".list" up-transition="cross-fade">
  Show users
</a>

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:

<form method="post" action="/users" up-target=".main">
  ...
</form>

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:

<form up-target=".selector-within-modal">
  ...
</form>

To close the modal, target a selector behind the modal:

<form up-target=".selector-behind-modal">
  ...
</form>

Server-side validations
without a page load

<form action="/users">

  <fieldset>
    <label>E-mail</label>
    <input type="text" name="email" up-validate>
  </label>

  <fieldset>
    <label>Password</label>
    <input type="password" name="password">
  </fieldset>

  <button type="submit">Register</button>

</form>

Server-side validations
without a page load

<form action="/users">

  <fieldset>                                    
    <label>E-mail</label>                       
    <input type="text" name="email" up-validate>
  </label>                                      

  <fieldset>
    <label>Password</label>
    <input type="password" name="password">
  </fieldset>

  <button type="submit">Register</button>

</form>

Server-side validations
without a page load

<form action="/users">

  <fieldset class="has-error">                                      
    <label>E-mail has already been taken!</label>                   
    <input type="text" name="email" up-validate value="foo@bar.com">
  </label>                                                          

  <fieldset>
    <label>Password</label>
    <input type="password" name="password">
  </fieldset>

  <button type="submit">Register</button>

</form>

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:

<select name="advancedness" up-switch=".target">
  <option value="basic">Basic parts</option>
  <option value="advanced">Advanced parts</option>
</select>

<div class="target" up-show-for="basic">
  only shown for advancedness = basic
</div>

<div class="target" up-hide-for="basic">
  hidden for advancedness = basic
</div>

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:

<form action="/contracts">
  <select name="department" up-validate="[name=employee]">...</select>
  <select name="employee">...</select>
</form>

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

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()

henning.koch@makandra.de
@triskweline

Additional slides

Update a page fragment
without JavaScript

Run example on unpoly.com

short.html

<div class="story">

  <p>Story summary</p>

  <a href="full.html" up-target=".story">
    Read full story
  </a>

</div>

<p>This text won't change</p>

full.html

<div class="story">

  <h1>Full story</h1>

  <p>Lorem ipsum dolor sit amet.</p>

  <a href="short.html" up-target=".story">
    Read summary
  </a>

</div>
  • 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 https://host/full.html

Open fragment in modal dialog
without JavaScript

Run example on unpoly.com

<div class="story">

  <p>Story summary</p>

  <a href="full.html" up-modal=".story">
    Read full story
  </a>

</div>
  • 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 <div class="up-modal">
  • Unpoly changes the browser URL to https://host/full.html

Open fragment in a popup menu
without JavaScript

Run example on unpoly.com

<div class="story">

  <p>Story summary</p>

  <a href="full.html" up-popup=".story">
    Read full story
  </a>

</div>
  • 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 <div class="up-popup">
  • Unpoly changes the browser URL to https://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

<div class="tasks">
  <li>Wash car</li>
  <li>Purchase supplies</li>
  <li>Fix tent</li>
</ul>

<a class="next-page" href="/tasks?page=2"
  up-target=".tasks:after, .next-page">Next page</a>

This appends the second page to the task list and replaces the "Next page" button with a link to page 3.

Persisting elements

<div class="story">

  <video up-keep src="movie.mp4"></video>

  <p>Story summary</p>

  <a href="full.html" up-target=".story">
    Read full story
  </a>

</div>



Updating persisted elements

<div class="map" up-data="[
  { lat: 48.36, lng: 10.99 },
  { lat: 48.75, lng: 11.45 }
]"></div>

<form method="post" action="/pins" up-target=".map">
  Lat: <input name="lat">
  Lng: <input name="lng">
  <button>Add pin</button>
</form>
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
    })
})
<div class="map" up-data="<%= @pins.to_json %>"></div>
 
 
 
 
<form method="post" action="/pins" up-target=".map">
  Lat: <input name="lat">
  Lng: <input name="lng">
  <button>Add pin</button>
</form>
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
    })
})
<div class="map" up-data="<%= @pins.to_json %>"></div>




<%= 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
    })
})
<div class="map" up-data="<%= @pins.to_json %>"></div>




<%= 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)
});
 
 
 
 
<div class="map" up-data="<%= @pins.to_json %>" up-keep></div>




<%= 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

<form action="/users" up-target=".list" up-autosubmit>
  <input type="search" name="query" />
</form>

<div class="list">
  <% @users.each do |user| %>
    <%= link_to user.email, user >
  <% end %>
</div>



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

<clock></clock>

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

<clock></clock>

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 <clock>!

  });
});

Unpoly compiler: Still leaky

HTML

<clock></clock>

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

<clock></clock>

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


Why transitions are hard


Ghosting

Old
position: static
display: hidden
New
position: static
opacity: 0
Old (ghost)
position: absolute
New (ghost)
position: absolute

Without ghosting


With ghosting


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

<span class="toggle-all">Toggle all</span>
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

<span class="toggle-all">Toggle all</span>
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

<span class="toggle-all">Toggle all</span>
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
IE 9 Page updates that change browser history
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

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?

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

<a menu-link href="/details">Show more</span>

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?