We’ve been using Hotwire at work recently. It’s been nothing short of revolutionary, but today I found an issue that arises because Hotwire is too damn user-friendly.

Let’s recap on Hotwire’s most basic element, the Turbo Frame, and how it’s used in Rails.

<%= turbo_frame_tag(message) do %>
    <dt>From:</dt><dd><%= message.from %></dd>
    <dt>To:</dt><dd><%= message.to %></dd>
    <dt>Content:</dt><dd><%= message.content %></dd>
  <%= link_to 'Edit', edit_message_path(message) %>
<% end %>

Once rendered, clicks on “Edit” will be intercepted and sent as a fetch rather than a full page request. The response should include the edit template wrapped in a matching turbo_frame_tag(message) tag. Hotwire replaces the existing Turbo Frame’s content with the incoming form and, just like that, 90% of the JavaScript I’ve ever had to write can be deleted.

Although Hotwire is framework agnostic, it’s primarily a 37Signals’ product, so it’s no surprise that Rails has special integrations for it. Compare how Rails responds to a regular GET versus a Turbo Frame, outputting the first line and byte-count.

$ curl -s 'http://localhost:3000/messages/1/edit' | tee >(head -n2) >(wc -c) >/dev/null
<!doctype html>

$ curl -s 'http://localhost:3000/messages/1/edit' -H 'Turbo-Frame: message_1' | tee >(head -n2) >(wc -c) >/dev/null
<turbo-frame id="message_1">

The Turbo response is a small HTML fragment instead of a full document. In Rails parlance, it renders the action, but not the layout. This make perfect sense: the layout represents the bulk of a site’s common structure - the HTML head, the page’s navigation, footer, etc - all of which sits outside any Turbo Frame and will therefore be thrown away. Why render text just to discard it?

Because Turbo is so forgiving in what it accepts we’d been using for weeks before noticing some interactions were unusually slow. On investigation we found all our Turbo Frame requests were returning bloated HTML documents instead of the zippy little fragments we expected.

The gotcha

To drop layout rendering for Turbo Frame requests, turbo-rails adds a conditional layout to ActionController::Base

module Turbo::Frames::FrameRequest
  extend ActiveSupport::Concern
  included do
    layout -> { false if turbo_frame_request? }

In a controller with no explicit layout the proc is invoked, followed by a walk up the class hierarchy for a similarly named layout, usually end at application.html.erb. In a Turbo Frame request the proc returning false terminates the search and the action is rendered with no layout.

In larger Rails applications though, it’s pretty common to have layouts that don’t follow the convention.

class MessagesController < ApplicationController
  layout 'custom'

With a static, custom layout the included proc is overwritten and all requests, Turbo Frames included, are rendered inside the ‘custom’ layout.

From a functional perspective, there’s no problem here: Hotwire ignores the extra markup and renders the Frame identically in either case. The problem is experiential: we waste server-time rendering HTML that’ll never be seen and the user gets a slower interaction.

The fix is simple: never use static layouts in apps that include Hotwire. Instead rely on layout names that match controllers or wrap custom layouts in a proc similar to turbo-rails.

class MessagesController < ApplicationController
  layout -> { turbo_frame_request? ? false : 'custom' }