Pitfalls & Burns

Turbo Streams

For Want of an ID

Bare dom_id and hand-written ids work until a record renders twice or a string drifts, and then streams fail silently. dom_target with real specificity, used on both sides, keeps the contract.

app/controllers/tasks_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class TasksController < ApplicationController
  def index
    @tasks = Task.order(:created_at)
    @pinned = Task.pinned.order(:created_at)
  end

  def update
    @task = Task.find(params[:id])
    @task.update!(task_params)
  end

  private

  def task_params
    params.expect(task: [ :completed ])
  end
end
The pitfall version. Completing a pinned task leaves its card stale, and clearing completed tasks changes nothing until a reload.
The refactored version. The row and the card update together, and clearing completed tasks updates both lists.

The scenario

We have a task list. Each task has a Done button, a sidebar called "Up next" shows the tasks we've pinned, and a button at the bottom clears completed tasks. Completing a task and clearing the list both respond with Turbo Streams, so the page updates without a reload.

The burn

Every stream targets an element by id, and nothing checks that the id exists. Turbo has no "content missing" for streams: if a target matches nothing, nothing happens. No exception on the server, no error in the console. The response is in the log, the partial rendered, and the page didn't change.

The first version reaches for dom_id(task) in both _task.html.erb and _pinned_card.html.erb. That's fine while each task appears once. But a pinned task appears twice, as a row and as a card, so the page now has two elements with the same id. update.turbo_stream.erb replaces dom_id(@task), which updates the first match only. The row changes; the card keeps saying "Not done".

The usual escape hatch is a hand-written string, and strings drift. index.html.erb renders the list as id="task-list", while completed_tasks/destroy.turbo_stream.erb targets "task_list". Clicking "Clear completed" destroys the records, the stream arrives, and it matches nothing. The page now shows tasks that no longer exist, and only a reload tells the truth.

The fix

Be specific about what each element is. It isn't "the task"; it's the task's row, or the task's pinned card. dom_target builds an id out of any mix of records, classes, and symbols: dom_target(task, :row), dom_target(task, :pinned_card), dom_target(Task, :list).

Then use it on both sides. Each partial wraps itself in the same dom_target call that the streams target, so there's no second copy of the address to misspell. _list.html.erb and _pinned_list.html.erb own their ids, and destroy.turbo_stream.erb replaces both. update.turbo_stream.erb replaces the row and, when the task is pinned, the card too.

Since the framework won't complain, we can make development complain for it. stream_guard_controller.js listens for turbo:before-stream-render and logs an error when a stream's target matches nothing, and a warning when it matches more than one element.

Why it matters

DOM ids are how streams, and frames, find their way around the page, and the contract between what the page renders and what the server targets is entirely unenforced. Frames at least fail loudly. Streams fail silent, which means the bug surfaces as a user reporting that the page lied to them.

So the discipline has to come from us: deliberate, specific ids from the start, one helper call shared by the markup and the stream, and a little tooling to catch the rest. Tests that find elements through the same dom_target calls will also fail on drift, even though the app never raises.

Read More