unpoly-rails: Ruby on Rails bindings for Unpoly

Unpoly is a backend-agnostic unobtrusive JavaScript framework.

unpoly-rails implements the [optional server protocol](https://unpoly.com/up.protocol] to give Ruby on Rails applications some convenience methods to communicate with an Unpoly-enhanced frontend.

For Rails applications using the Asset Pipeline, unpoly-rails also ships the latest Unpoly JavaScript and CSS files. If you're using Webpacker you can consume the npm package instead.

Features

The methods documented below are available in all controllers, views and helpers.

Detecting a fragment update

Use up? to test whether the current request is a fragment update:

up? # => true or false

To retrieve the CSS selector that is being updated, use up.target:

up.target # => '.content'

The Unpoly frontend will expect an HTML response containing an element that matches this selector. Your Rails app is free to render a smaller response that only contains HTML matching the targeted selector. You may call up.target? to test whether a given CSS selector has been targeted:

if up.target?('.sidebar')
  render('expensive_sidebar_partial')
end

Fragment updates may target different selectors for successful (HTTP status 200 OK) and failed (status 4xx or 5xx) responses. Use these methods to inspect the target for failed responses:

  • up.fail_target: The CSS selector targeted for a failed response
  • up.fail_target?(selector): Whether the given selector is targeted for a failed response
  • up.any_target?(selector): Whether the given selector is targeted for either a successful or a failed response

Changing the render target

The server may instruct the frontend to render a different target by assigning a new CSS selector to the up.target property:

unless signed_in?
  up.target = 'body'
  render 'sign_in'
end

The frontend will use the server-provided target for both successful (HTTP status 200 OK) and failed (status 4xx or 5xx) responses.

Rendering nothing

Sometimes it's OK to render nothing, e.g. when you know that the current layer is to be closed.

In this case you may call up.render_nothing:

class NotesController < ApplicationController
  def create
    @note = Note.new(note_params)
    if @note.save
      if up.layer.overlay?
        up.accept_layer(@note.id)
        up.render_nothing
      else
        redirect_to @note
      end
    end
  end
end

This will render a 200 OK response with a header X-Up-Target: none and an empty body.

You may render nothing with a different HTTP status by passing a :status option:

up.render_nothing(status: :bad_request)

Pushing a document title to the client

To force Unpoly to set a document title when processing the response:

up.title = 'Title from server'

This is useful when you skip rendering the <head> in an Unpoly request.

Emitting events on the frontend

You may use up.emit to emit an event on the document after the fragment was updated:

class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
    up.emit('user:selected', id: @user.id)
  end

end

If you wish to emit an event on the current layer instead of the document, use up.layer.emit:

class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
    up.layer.emit('user:selected', id: @user.id)
  end

end

Detecting an Unpoly form validation

To test whether the current request is a form validation:

up.validate?

When detecting a validation request, the server is expected to validate (but not save) the form submission and render a new copy of the form with validation errors. A typical saving action should behave like this:

class UsersController < ApplicationController

  def create
    user_params = params[:user].permit(:email, :password)
    @user = User.new(user_params)
    if up.validate?
      @user.valid?  # run validations, but don't save to the database
      render 'form' # render form with error messages
    elsif @user.save?
       @user
    else
      render 'form', status: :bad_request
    end
  end

end

Detecting a fragment reload

To test whether the current request was made to reload or poll a fragment:

up.reload?

You also retrieve the time when the fragment being reloaded was previously inserted into the DOM:

up.reload_from_time # returns a Time object

The server can compare the time from the request with the time of the last data update. If no more recent data is available, the server can render nothing:

  class MessagesController < ApplicationController

    def index
      if up.reload_from_time == current_user.last_message_at
        up.render_nothing
      else
        @messages = current_user.messages.order(time: :desc).to_a
        render 'index'
      end
    end

  end

Only rendering when needed saves CPU time on your server, which spends most of its response time rendering HTML.

This also reduces the bandwidth cost for a request/response exchange to ~1 KB.

Working with context

Calling up.context will return the context object of the targeted layer.

The context is a JSON object shared between the frontend and the server. It persists for a series of Unpoly navigation, but is cleared when the user makes a full page load. Different Unpoly layers will usually have separate context objects, although layers may choose to share their context scope.

You may read and change the context object. Changes will be sent to the frontend with your response.

class GamesController < ApplicationController

  def restart
    up.context[:lives] = 3
    render 'stage1'
  end

end

Keys can be accessed as either strings or symbols:

puts "You have " + up.layer.context[:lives] + " lives left"
puts "You have " + up.layer.context['lives'] + " lives left"

You may delete a key from the frontend by calling up.context.delete:

up.context.delete(:foo)

You may replace the entire context by calling up.context.replace:

context_from_file = JSON.parse(File.read('context.json))
up.context.replace(context_from_file)

up.context is an alias for up.layer.context.

Accessing the targeted layer

Use the methods below to interact with the layer of the fragment being targeted.

Note that fragment updates may target different layers for successful (HTTP status 200 OK) and failed (status 4xx or 5xx) responses.

up.layer.mode

Returns the mode of the targeted layer (e.g. "root" or "modal").

up.layer.root?

Returns whether the targeted layer is the root layer.

up.layer.overlay?

Returns whether the targeted layer is an overlay (not the root layer).

up.layer.context

Returns the context object of the targeted layer. See documentation for up.context, which is an alias for up.layer.context.

up.layer.accept(value)

Accepts the current overlay.

Does nothing if the root layer is targeted.

Note that Rails expects every controller action to render or redirect. Your action should either call up.render_nothing or respond with text/html content matching the requested target.

up.layer.dismiss(value)

Dismisses the current overlay.

Does nothing if the root layer is targeted.

Note that Rails expects every controller action to render or redirect. Your action should either call up.render_nothing or respond with text/html content matching the requested target.

up.layer.emit(type, options)

Emits an event on the targeted layer.

up.fail_layer.mode

Returns the mode of the layer targeted for a failed response.

up.fail_layer.root?

Returns whether the layer targeted for a failed response is the root layer.

up.fail_layer.overlay?

Returns whether the layer targeted for a failed response is an overlay.

up.fail_layer.context

Returns the context object of the layer targeted for a failed response.

Managing the client-side cache

The Unpoly frontend caches server responses for a few minutes, making requests to these URLs return instantly. Only GET requests are cached. The entire cache is cleared after every non-GET request (like POST or PUT).

The server may override these defaults. For instance, the server can clear Unpoly's client-side response cache, even for GET requests:

up.cache.clear

You may also clear a single page:

up.cache.clear('/notes/1034')

You may also clear all entries matching a URL pattern:

up.cache.clear('/notes/*')

You may also prevent cache clearing for an unsafe request:

up.cache.keep

Here is an longer example where the server uses careful cache management to keep as much of the client-side cache as possible:

def NotesController < ApplicationController

  def create
    @note = Note.create!(params[:note].permit(...))
    if @note.save
      up.cache.clear('/notes/*') # Only clear affected entries
      redirect_to(@note)
    else
      up.cache.keep # Keep the cache because we haven't saved
      render 'new'
    end
  end
  ...
end

unpoly-rails patches redirect_to so Unpoly-related request information (like the CSS selector being targeted for a fragment update) will be preserved for the action you redirect to.

Automatic redirect detection

unpoly-rails installs a before_action into all controllers which echoes the request's URL as a response header X-Up-Location and the request's HTTP method as X-Up-Method.

Automatic method detection for initial page load

unpoly-rails sets an _up_method cookie that Unpoly needs to detect the request method for the initial page load.

If the initial page was loaded with a non-GET HTTP method, Unpoly will fall back to full page loads for all actions that require pushState.

The reason for this is that some browsers remember the method of the initial page load and don't let the application change it, even with pushState. Thus, when the user reloads the page much later, an affected browser might request a POST, PUT, etc. instead of the correct method.

What you still need to do manually

Failed form submissions must return a non-200 status code

Unpoly lets you submit forms via AJAX by using the form[up-follow] selector or up.submit() function.

For Unpoly to be able to detect a failed form submission, the form must be re-rendered with a non-200 HTTP status code. We recommend to use either 400 (bad request) or 422 (unprocessable entity).

To do so in Rails, pass a :status option to render:

class UsersController < ApplicationController

  def create
    user_params = params[:user].permit(:email, :password)
    @user = User.new(user_params)
    if @user.save?
       @user
    else
      render 'form', status: :bad_request
    end
  end

end

Development

Before you make a PR

Before you create a pull request, please have some discussion about the proposed change by opening an issue on GitHub.

Running tests

  • Install the Ruby version from .ruby-version (currently 2.3.8)
  • Install Bundler by running gem install bundler
  • Install dependencies by running bundle install
  • Run bundle exec rspec

The tests run against a minimal Rails app that lives in spec/dummy.

Making a new release

Install the unpoly-rails and unpoly repositories into the same parent folder:

projects/
  unpoly/
  unpoly-rails/

During development unpoly-rails will use assets from the folder assets/unpoly-dev, which is symlinked against the dist folder of the `unpoly repo.

Before packaging the gem, a rake task will copy symlinked files assets/unpoly-dev/* to assets/unpoly/*. The latter is packaged into the gem and distributed.

projects/
  unpoly/
    dist/
      unpoly.js
      unpoly.css
  unpoly-rails
    assets/
      unpoly-dev   -> ../../unpoly/dist
        unpoly.js  -> ../../unpoly/dist/unpoly.js
        unpoly.css -> ../../unpoly/dist/unpoly.css
      unpoly
        unpoly.js
        unpoly.css

Making a new release of unpoly-rails involves the following steps:

  • Make a new build of unpoly (npm run build)
  • Make a new release of the unpoly npm package
  • Bump the version in lib/unpoly/rails/version.rb to match that in Unpoly's package.json
  • Commit and push the changes
  • Run rake gem:release