Alex's Slip-box

These are my org-mode notes in sort of Zettelkasten style

Turbo Streams

:ID: a9825c81-8f3b-42f7-acb8-f5fb4ee359b9

Turbo streams are part of the Turbo library, which itself is part of Hotwire. The idea with turbo streams is to have the server render some HTML, but the frontend handle that HTML response in some declared way (eg, the action). The actions, which are preformed with reference to a target DOM element (referenced by the element’s ID), can be:

  • append
  • prepend
  • replace
  • update
  • remove
  • before
  • after

A turbo stream can either be a response from the server, or pushed to the frontend client over web sockets. Either way, the outcome is an update to a part of the DOM.

The mechanics are well explained in the docs, but it wasn’t completely clear to me on how this is integrated with Rails via the turbo-rails gem.

See also Hotwire rails

# A turbo stream request

How does the front end send an HTTP request to the sever indicating that it wants a turbo stream response? This is done by setting the Accept header to text/vnd.turbo-stream.html

curl --request GET \
--url http://localhost:3000/messages/new \
--header 'Accept: text/vnd.turbo-stream.html'

In order to have the Rails frontend perform HTTP requests with the turbo stream format say for a link (GET request), set the tubo_stream data property.

link_to 'New Message', new_message_path, data: { turbo_stream: 'true'}

For POST requests, this is done automatically.

# The turbo stream response

The controller should be configured to handle a turbo stream format.

def new
  @message = Messages.new
  respond_to do |format|
    format.turbo_stream
  end
end

This will implicitly render the view template app/views/messages/new.turbo_stream.haml within which the turbo_stream tag helper can be used to generated the expected turbo frame HTML. See Turbo::Streams::TagBuilder for a dive into the implementation.

= turbo_stream.prepend 'messages' do
  = render MessageFormComponent.new(message: @message)

Any of the above actions can be used, called as a method on the TagBuilder object. The argument, messages , is the DOM element ID that should be targeted, in this case the container to which the message form should be prepended.

# turbo-stream tag

The server will render the following with help from the turbo_stream tag helper:

<turbo-stream action="prepend" target="messages">
  <template>
    <form action="/messages" accept-charset="UTF-8" method="post">
      <!-- etc, etc, etc -->
    </form>
  </template>
</turbo-stream>

The key things are:

  1. The turbo-stream tag which declares both the action (see list above) and the target (the DOM id on which the action is applied)
  2. A template tag.

# Rendering inline with view components

View components can be rendered from the controller for turbo stream requests instead of defining the new.turbo_stream.haml template.

def new
  @message = Message.new
  respond_to do |format|
    format.turbo_stream do
      render inline: turbo_stream.prepend('messages', MessageFormComponent.new(message: @message).render_in(view_context))
    end
  end
end

See also

# Deletes

Deletes can be handled also by setting data attributes on a link, optionally with a confirmation prompt.

= link_to 'Delete', @message, data: { turbo_method: 'delete', turbo_confirm: 'Are you sure?' }

What does the sever respond with? We can keep it simple with an inline render:

def destroy
  message = Message.find(params[:id])
  message.destroy
  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: turbo_stream.remove(message)
    end
    format.html { redirect_to messages_path }
  end
end

This action will respond with the following markup, form which turbo will remove the DOM element with id message_4 by returning an empty, templateless, turbo-frame element.

<turbo-stream action="remove" target="message_4"></turbo-stream>

# What about redirects?

We might just want to redirect instead, even conditionally. If we’re reusing view components or partials, we might have the ability to delete from an index page and from a show page. It doesn’t make sense to return a turbo stream that removes elements on the show page since the resource is being deleted.

def destroy
  message = Messages.find(params[:id])
  message.destroy
  respond_to do |format|
    format.turbo_stream do
      if request.referrer == messages_url
        render turbo_stream: turbo_stream.remove(message)
      else
        redirect_to messages_path, status: :see_other, notice: 'Message was deleted'
      end
    end
    format.html { redirect_to messages_path }
  end
end

NOTE the status see_other (303) which is used in response to a PUT or POST where the redirect is meant to be a GET. See also https://github.com/hotwired/turbo/issues/84

# turbo_stream_from (turbo streams over WebSocket connection)

This method is used in the view templates to generate a turbo-cable-stream-source HTML tag with a couple data attributes describing the channel and stream.

= turbo_stream_from current_user, 'posts'

The above generates: (See also https://turbo.hotwired.dev/handbook/streams#integration-with-server-side-frameworks)

<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="verylongsignedstring"
></turbo-cable-stream-source>

The Turbo::StreamsChannel ActionCable channel is dedicated to turbo streams. The stream name is signed based on the “streamables” (eg, current_user and ’posts’) for a bit of security. The streamables can include multiple objects to narrow scope of the stream. Include the a user object to setup a stream for a particular user’s posts

Now to broadcast a message, you can do this from the code:

Turbo::StreamsChannel.broadcast_prepend_to([user, 'posts'], target: 'posts', content: 'HELLO')

There’s a helper method for each turbo stream action that wraps the generic broadcast_action_to method. See also:

NOTE: instead of content one can use partial and point to the partial path.

# with view_component components

There are several approaches to this, all kind of hacky. See also https://github.com/ViewComponent/view_component/issues/1106

Turbo::StreamsChannel.broadcast_action_to(
  [user, 'posts'],
  target: 'post_20',
  content: PostComponent.new(post:).render_in(ActionController::Base.new.view_context)
  action: :replace
)

Might be helpful to wrap this in a function

module ViewComponentBroadcaster
  module_function

  def call(streamables, component:, **options)
    Turbo::StreamsChannel.broadcast_action_to(
      streamables,
      target: component.id,
      content: component.render_in(ActionController::Base.new.view_context),
      **options
    )
  end
end

ViewComponentBroadcaster.call([user, 'posts', component: PostComponent.new(post:), action: :replace)

# Programatically submitting a Turbo form with JS

Use requestSubmit instead of submit which does not emit a submit event that Turbo is listening for. https://stackoverflow.com/a/69537709

# TurboStream request with fetch

const body = JSON.stringify({ ... });
const headers = {
  'Content-Type': 'application/json',
  Accept: 'text/vnd.turbo-stream.html',
  'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
}

const response = await fetch('/foo', {
  method: 'POST',
  body,
  headers
})

const responseBody = await response.text()
// Option 1: use Turbo.renderStreamMessage(responseBody)

// Option 2: If option 1 isn't an option, manually handle the response:
const tempTemplate = document.createElement('template')
tempTemplate.innerHTML = responseBody
const content = tempTemplate.content.querySelector('template').content
// Now do whatever with the response content

# Custom TurboStream ActionCable Channel

Search Results