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 %>
<dl>
<dt>From:</dt><dd><%= message.from %></dd>
<dt>To:</dt><dd><%= message.to %></dd>
<dt>Content:</dt><dd><%= message.content %></dd>
</dl>
<%= 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>
69479
$ 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">
2049
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? }
end
end
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'
end
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' }
end