um

Turbo

Turbo Stream actions

turbo_stream.toast and turbo_stream.stream_markdown, and an upsert action that merges append and replace.

The engine adds two actions to turbo-rails' turbo_stream builder when turbo-rails loads, so an app without Turbo never sees them. The JavaScript adds a third action to Turbo itself.

turbo_stream.toast

Pops a toast from a stream response, for something that happened outside the flash cycle, where there's no redirect or render to carry a flash. It appends to the flash_toasts mount, so the layout needs one.

render turbo_stream: turbo_stream.toast("Invitation sent.")
render turbo_stream: turbo_stream.toast("Couldn\'t reach Slack.", tone: :bad, duration: 0)

It takes tone: (:good by default, then :warn, :bad, :info, :neutral, :accent, :inverted), title:, icon:, duration: in milliseconds (0 waits to be dismissed), position:, width: and layout:. A block yields a builder for actions, a leading slot and a body:

<%= turbo_stream.toast "Your export is ready.", title: "Reports" do |toast| %>
  <% toast.actions do %>
    <%= link_to "Download", export_path(@export), class: button_classes(size: :small) %>
  <% end %>
<% end %>

turbo_stream.stream_markdown

Hands a reply rendered so far to the streaming_markdown_tag with that id, which paces its way toward it. Send the whole render every time, not the new part: the element works out what's new.

turbo_stream.stream_markdown "message_1_content", Markdown.render(message.content)

# From a model, through Turbo::Broadcastable
broadcast_action_to chat, action: :stream_markdown, target: "#{dom_id(self)}_content",
  html: Markdown.render(content)

Needs import "unmagic/components/streaming_markdown".

upsert

A stream action that merges append and replace. Its template holds one element with a stable id. If that id is already on the page (rendered on load, streamed earlier, or drawn optimistically) the element is replaced in place, so a record that's both on the page and broadcast never shows twice. Otherwise it's inserted into the target at the position its id sorts to, so messages that arrive out of order still land in order.

render turbo_stream: turbo_stream.action(:upsert, "task_rows", partial: "tasks/row", locals: { task: @task })

# Newest first: the order flips
broadcast_action_to "tasks", action: :upsert, target: "task_rows",
  attributes: { order: "desc" }, partial: "tasks/row", locals: { task: self }

The ordering assumes time-ordered ids, such as UUIDv7 or ULID. With random ids the position is stable but arbitrary. Needs import "unmagic/components/upsert", which "unmagic/components" includes.

See it working