RenderTurboStream

This gem has a second README Turbo::StreamsChannel. Starting point is here. And I recommend that you download my Quick-and-dirty test project, set it up, see all the tests succeed, and then read through this README.

As of v4.3, locals inside partials should work as expected. If you are working with turbo but without this gem, please read readme-locals to avoid tedious details.

Defining templates like (create|update).turbo_stream.haml is a heavy mix of logic and view. This gem separates logic and view so that *.turbo_stream.* templates are no longer necessary and the logic can stay on the ruby side.

For :replace actions, responses must be wrapped inside a turbo_frame_tag with a matching target_id. The gem will regex the content and wrap it by a turbo_frame_tag if necessary but not present. This way the matching target_id is only defined in one place. This check only happens on the first call after restarting the application in production and on every call if precompile assets is set to true in configs.

There are many different ways to handle redirects since turbo. Some details are handled here.

Execute turbo_power commands such as adding a css class to an html element, can be sent directly from the controller.

Has a testing strategy.

An overview of how we design a rails-7 application with turbo is published on dev.to.

Chris

Installation

gem 'render_turbo_stream'
bundle install

Include the helpers

ApplicationController

include RenderTurboStream::ControllerHelpers

Create a Initializer

ActionView::Base.send :include, RenderTurboStream::ViewHelpers

spec/rails_helper.rb (for rspec)

RSpec.configure do |config|
  #...
  config.include RenderTurboStream::Test::Request::Helpers, type: :request
  config.include RenderTurboStream::Test::Request::ChannelHelpers, type: :request
  config.include RenderTurboStream::Test::System::Helpers, type: :system
end

Flash

Required Configurations for Flash Partial

    config.x.render_turbo_stream.flash_partial = 'layouts/flash'
    config.x.render_turbo_stream.flash_target_id = 'flash-box'
    config.x.render_turbo_stream.flash_turbo_action = 'prepend'

    # In case of redirection a flash cannot be sent by TurboStream
    config.x.render_turbo_stream.allow_channel_to_me_for_turbo_stream_save = true

    config.x.render_turbo_stream.store_last_saved_object = true # default: false
    # saves the last successful saved record into session['last_saved_object'] as global_id
    # this can be accessed by view_helper #last_saved_object

For the latter you have to setup channels, see below.

The corresponding partials for flashes could look like this

Translations

en:
  activerecord:
    render_turbo_stream_success:
      create: '%<model_name>s successfully created'
      update: '%<model_name>s successfully updated'
      destroy: '%<model_name>s successfully destroyed'
    render_turbo_stream_errors:
      create: '%<model_name>s could not be created'
      update: '%<model_name>s could not be updated'
      destroy: '%<model_name>s could not be destroyed'

The last key (e.g. update) marks the controller action.

Model name translations, see: Rails Docs.

Turbo power

To get redirection and many other options working, you need to follow the installation steps from turbo_power.

Turbo itself

A comprehensive tutorial on turbo and how to check that it is working properly can be found at hotrails.dev.

Turbo::StreamsChannel

The Rails team has integrated ActionCable as Turbo::StreamsChannel into Turbo Rails. For installation along with this gem, see the README-channels.

Usage

render_turbo_stream

The basic idea for this gem is to have all the decisions/logic within the controller so that the views are just views. So in the end we have an array of turbo stream actions that are handled by the gem. In normal cases, *.turbo_stream.* templates are not needed anymore.

render_turbo_stream(
  [
    {
      partial: 'customers/customer_form' #=> The target id can be defined within the form and retrieved during rendering. The default :action is :replace
    },
    {
      id: 'flash-wrapper',
      partial: 'layouts/flashes',
      action: :prepend,
      locals: { success: true }
    },
    [
      :push_state,
      '/articles/my-special-path'
    ],
    [
      :add_css_class,
      '#colored-element',
      'red'
    ]
  ]
)

for just rendering a partial there is a helper:

stream_partial(
  'form',
  locals: {}
)

this enables:

  • turbo-stream or custom javascript actions from turbo and all actions from turbo_power
  • Testing helpers (request-testing), as mentioned later
  • Helpers for handling the target id, as mentioned later

If element is an array instead of a hash, it does this under the hood: = turbo_stream.send args.first, *(args[1..-1]). If element is a hash, it is interpreted as a partial or template to render.

turbo_stream_save

turbo_stream_save is a special method for update or create functions with turbo_stream. A controller action for update might look like this:


def create
  turbo_stream_save(
    @article.update(article_params),
    if_success_redirect_to: articles_path,
    partial: 'form'
  )
end
  • uses render_turbo_stream or render_to_me (if allowed by config) under the hood
  • Generates translated flash messages
  • Handles redirects: Either classic redirect, which would never break out of a turbo-frame or the redirect from turbo_power which does a full-page-reload.

Note that if you want to redirect to show path, but the save action fails, article_path(@article) would throw an error. For this, the arguments if_success_turbo_redirect_to and if_success_redirect_to accept procs that are evaluated only on success. So your line would look like this if_success_redirect_to: ->{article_path(@article)}.

Config: allow_channel_to_me_for_turbo_stream_save

Turbo streams rely on the controller response, which can only be executed once for a request. Other than Turbo::StreamsChannel, they cannot run in parallel with the standard responses like redirect_to.

If this config is set to true, Turbo::StreamsChannel is installed and a current user is logged in:

If an if_success_redirect_to argument is provided and the save action was successful, turbo_stream_save would send the partials by channel.

turbo_target_tag

Assuming we want to build a form that works within a turbo-frame, including flash messages and redirects, we have to build all that inside a turbo-frame, and, within that we need a target-id, that must not be a turbo-frame. For that, there is a helper:

= turbo_target_tag do
  = simple_form ... 
  • Generates an element like <turbo-target id="new-article-form">
  • If no target-id is defined on turbo_stream_save or render_turbo_stream the renderer will grab inside the rendered content for a turbo-frame or turbo-target and get the target-id from there, so you can avoid having to define the same target-id on multiple places.

Target-ID

The target ID for turbo has to be unique for sure, and it has to be nice, because, at least during testing the developer has to deal with it. Since the default ID builder dom_id is too simple for this, there are some helpers. How it works is best shown by the request-test helper target_id:

# target_id(virtual_view_path, object)
target_id('customers/_form', Customer.new) #=> 'new-customer-form'
target_id('form', Customer.new) #=> 'new-customer-form'

View-helper: Assuming we are inside customers/_my_form:

#=> Assuming controller-name "customers" and variable @customer is present
target_id 
#=> 'customer-1-my-form'

target_id(Customer.first) #=> 'customer-1-my-form'
target_id( [Customer.first, Article.last, 'hello'] ) #=> 'customer-1-article-7-hello-my-form'
target_id('hi-joe') #=> 'hi-joe'

target_id_css('hi-joe') #=> '#hi-joe' 
#=> is available for system tests, example for capybara: «within target_id_css('articles/_form', @article) do ..

Why include the filename in a html-id? Because for turbo its likely to have multiple cases for the same object on the same page, for example: _form, _show, _password. These can all be the same customer.

Target-ID: Avoid the same definition in multiple places

Without this gem a turbo action would be wrapped within two frames, for example:

= turbo_stream.replace 'target-id' do 
  = render 'a partial'

and within a partial:

= turbo_frame_tag 'target-id' do
   ... content

The turbo_frame_tag would stay inside a partial in most cases. The reason is: On the first load, the target-id must be delivered up front, so that if the tag needs to be replaced later, turbo knows which part to replace.

This means that the target id must be defined in several places: inside a partial and at the place where the turbo action is defined.

In order to avoid this kind of tedious coding, the gem has a kind of fallback built in: If the argument partial is given, but the attribute target_id is not, the gem will get the target_id from the partial. The process is:

  1. Render the partial with the provided locals
  2. Grabs into the partial by Nokogiri and looks for the first turbo-frame or turbo-target element, get the id and uses this as target_id.
  3. If all that not is found it raises a exception
  4. wraps the partial within the turbo_stream.* and sends this to the front.

Debugging

On :debug level (rails-docs), if a save action fails, turbo_stream_save and turbo_channel_save are logging the output from Model.errors.full_messages to the console.

Request Testing

To test if the whole system works together, including javascript actions, so that finally a part reaches the surface, there is Capybara system testing. But it is a good practice to break tests into smaller pieces. So, there are helpers for enabling the much faster request tests that are much easier to maintain.

If the request format is not turbo_stream, which is the case on request specs, the method responds in a special html that contains the medadata that is interesting for our tests and is parsed by included test helpers.

There is a helper for writing the test: In the debugger, within the test, check the output of all_turbo_responses.

Response Status

The turbo_stream_save method sets three statuses to the response: 200 if save_action is true, otherwise 422 and 302 for redirect. If one of the declared partials does not exist or breaks, the server will respond with exception anyway.

Redirection

If you defined the attribute if_success_turbo_redirect_to which uses the redirect_to function from turbo_power if installed:

it 'update success' do
  patch article_path(article, params: valid_params)
  assert_turbo_redirect_to('/articles')
end

Test turbo stream response

expect(turbo_targets.length).to eq(2)
# Check the total number of targeted html-ids, in most cases it will be one form and one flash.

assert_stream_action('turbo_frame_set_src'){ |args| args == ["cars-box", "/cars"] }
# if the turbo_power gem is installed

assert_stream_response('form'){|e|e.css('.field_with_errors').inner_html.include?('title')}
# make sure that there is one response to the target '#form' and it checks the rendered content
# if cero or more responses are expected, add a attribute like 'count: 0'

Possible matchers for checking html content can be found at Nokogiri.

The assert_stream_response checks by default that the same target is not affected multiple times by the :replace action within the same response. This is defined in the underlying RenderTurboStream::Test::Request::Libs.select_responses method, which is also used for the assert_channel_to_* methods.

More Configs

On test helpers, the marker for a turbo-stream target is in most cases the id of the target element. This is true for the standard turbo-stream functions. On turbo_power it is the same in most cases. RenderTurboStream::Test::Request::Libs.first_arg_is_html_id() checks for which methods this is true. You can override this:

config.x.render_turbo_stream.first_argument_is_html_id = %[replace append prepend turbo_frame_set_src]

This setting is relevant for testing helpers.

Contributing

Contributors welcome.

License

The gem is available as open source under the terms of the MIT License.

Thanks

Thanks to Marco Roth for turbo_power and thanks to DHH and the rails team for taking a bold new step by creating turbo.