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
Based on chart by @ryanstout
Complexity in 2005
Authorization
Routing
Models
Controllers
Views
Dependencies
Server
Based on chart by @ryanstout
Complexity in 2008
Authorization
Routing
Models
Controllers
Views
Dependencies
Server
Based on chart by @ryanstout
Complexity in 2009
API
Authorization
Routing
Models
Controllers
Views
Dependencies
Server
Based on chart by @ryanstout
Complexity in 2011
Asset packing
API
Authorization
Routing
Models
Controllers
Views
Dependencies
Server
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:
In hindsight, these are the projects that should have been a SPA:
YMMV.
Learnings from 3 years of SPAs
-
SPAs are a good fit for a certain class of UI.
For us that class is the exception, not the default. - There's a trade-off between UI fidelity and code complexity.
-
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
- Wide choice of great and mature languages
- Low complexity stack
- Synchronous data access
- Time to first render
- Works on low-end devices
What server-side apps
don't do well
- Slow interaction feedback
-
Page loads destroy transient state
(scroll positions, unsaved form values, focus)
-
Layered interactions are hard
(modals, drop-downs, drawers)
- Animation is complicated
- 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:
-
Navigate between cards, open and cancel the form
Page transitions are animated
-
Navigate between cards in the left pane
Scroll positions are kept in both panes
-
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
-
Edit a card, change the title, then change the pattern
Pattern selection happens in a modal dialog,
preserving unsaved form values -
Inspect links and see attributes with
up-*prefix
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
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-currentclass 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.wysiwygshould activate Redactor on load -
An
input[type=search]field should automatically request new results
when the user types something -
A
button.toggle-allshould toggle all checkboxes when clicked -
A
.mapshould 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-activeclass while loadingGet into a habit of styling
.up-activefor instantaneous feedback
Use throttling and Chrome's network tab -
Links with an
[up-instant]attribute load onmousedowninstead ofclickSaves ~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 hoveringSaves ~200-400 ms minus configured delay (test yourself)
-
Responses to
GETrequests are cached for 5 minutesAny non-
GETrequest 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
- Slow interaction feedback
-
Page loads destroy transient state
(scroll positions, unsaved form values, focus)
-
Layered interactions are hard
(modals, drop-downs, drawers)
- Animation is complicated
- Complex forms
What server-side apps
can actually be okay at
- Slow interaction feedback
-
Page loads destroy transient state
(scroll positions, unsaved form values, focus)
-
Layered interactions are hard
(modals, drop-downs, drawers)
- Animation is complicated
- Complex forms
Getting started
- Check out unpoly.com to get an overview of what's included
npm install unpoly --saveorgem 'unpoly-rails'(other methods)- Replace half your JavaScript with
[up-target]attributes - Convert remaining JavaScripts into
up.compiler()
Additional slides
Update a page fragment
without JavaScript
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.htmlvia 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
<div class="story">
<p>Story summary</p>
<a href="full.html" up-modal=".story">
Read full story
</a>
</div>
- Unpoly requests
full.htmlvia 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
<div class="story">
<p>Story summary</p>
<a href="full.html" up-popup=".story">
Read full story
</a>
</div>
- Unpoly requests
full.htmlvia 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
});
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
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?
- 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
<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?