Pitfalls & Burns

Turbo Frames

Have You Seen This Content?

Three unrelated failures (a template that forgot its wrapper, a deleted record, an expired session) and Turbo answers all of them with the same two words, "Content missing."

app/models/submission.rb
1
2
3
4
5
6
7
8
class Submission < ApplicationRecord
  # Most submissions render through the standard detail template. "digest" is a
  # newer type whose detail template was added later — and, as it happens, was
  # written without the frame wrapper the panel expects.
  def digest?
    kind == "digest"
  end
end
The review queue, pitfall version. An archived submission, the digest, and an expired session all end in "Content missing." The yellow panel stands in for changes made in another tab.
The review queue, refactored version. The digest loads, the archived submission shows a message, and the expired session goes to the sign-in page.

The scenario

We're building a review queue for moderators: a list of submissions on the left, and a panel on the right that shows whichever one we click. The panel is a Turbo Frame with the id submission_panel, and each link in the queue targets it. The show action renders its content inside a frame with the same id, and Turbo swaps it in.

The burn

That frame id is a contract: every response a frame request can receive has to contain a matching frame. When one doesn't, Turbo replaces the panel with "Content missing" and throws a TurboFrameMissingError that names the frame but not the URL or the status code.

The contract breaks in two different ways. The first is a bug in our own templates. A new digest submission type was added later, and show.html.erb renders _digest.html.erb for it, which never wraps itself in the frame. The response is a perfectly good 200, with nothing Turbo can use.

The second is every other response the frame might get. Another moderator archives a submission after the queue has loaded, so the scoped lookup in submissions_controller.rb raises, and rescue_from renders the app's friendly not_found.html.erb. Or the session expires, and the request redirects to sign_in.html.erb. Both are full pages with no frame in them. The signed-out case is the worst: the moderator is still looking at the queue, with no sign-in form and no explanation.

Three unrelated causes, one identical symptom.

The fix

We split the causes into bugs to fix and conditions to handle.

The digest is a bug, so we fix it: _digest.html.erb now wraps itself in the submission_panel frame.

The other two will keep happening, so we handle them. In index.html.erb we wrap the frame in an element that listens for turbo:frame-missing. Because the event bubbles, the error message can be ordinary server-rendered markup that starts out hidden. frame_recovery_controller.js cancels Turbo's default and reports the frame id, status, and URL first. Then it recovers: if the response was a redirect, it calls the event's visit so the sign-in page takes over the whole window; anything else shows the inline message and leaves the rest of the page alone.

Why it matters

The obvious fix is a handler that swaps "Content missing" for a friendlier message. That hides the one signal we had. Turbo's default at least throws an error our exception tracker can catch, and a blanket handler turns template bugs into polite messages nobody investigates. So the handler reports before it recovers, and flags a 200 with no frame as a likely template bug.

Error pages are part of the frame contract too: any rescue_from or auth redirect is a response some frame might receive.

Turbo 7.2 turned a missing frame into a full-page visit on its own, so an expired session just worked. Turbo 7.3 replaced that with "Content missing" and an error. Calling visit(response) in a handler puts the old behavior back, one frame at a time.

Read More