How Rails picks a template engine, and what a handler really is
Most Rails developers write ERB for years without ever asking what turns it into HTML. The answer is smaller and stranger than you'd expect, and it explains two things that otherwise look like Rails being mysterious: why views are fast after the first request, and why a typo in a filename silently prints your template instead of raising.
Everything below was measured on Rails 8.1.4.
There are five handlers, and that's all
ActionView::Template::Handlers.extensions
# => [:builder, :erb, :html, :raw, :ruby]
That's a fresh app. Not a long list, and it's worth noticing what isn't on it: Slim and Haml. They aren't special cases Rails knows about. They're gems that call the same registration method you're about to see, and their extensions appear in that array once they're loaded.
html and raw both pass the file through untouched. ruby evaluates it as Ruby. builder is
the XML DSL, which is still how most people generate a sitemap.
A handler returns Ruby, not HTML
This is the part that reframes everything.
source = ActionView::Template.handler_for_extension(:erb).call(template, "<h1><%= 1 + 1 %></h1>")
source.class # => String
source.length # => 133
A 133 character string of Ruby, for a 21 character template. It contains the literal <h1>, it
contains the expression 1 + 1, and it mentions an output buffer, because what it describes is a
program that appends pieces to a buffer and returns it.
So a handler is a compiler. It never sees a request, never produces HTML, and runs once per template rather than once per render. Rails takes that string, defines a method from it on the view class, and every subsequent render calls the method.
That's the whole reason views are quick. The first render of a page pays for compiling ERB. The ten thousandth is a method call.
The fallback that eats your typos
ActionView::Template.handler_for_extension(:erb) # => ActionView::Template::Handlers::ERB
ActionView::Template.handler_for_extension(:nope) # => ActionView::Template::Handlers::Raw
Ask for an extension nobody registered and you get Raw, which returns the file's contents as a
string. No exception, no warning.
This is why a template saved as show.html.erbb, or a Slim file in an app where the Slim gem
isn't loaded, renders as text in the browser with all its tags and interpolations visible. The
framework did exactly what it was asked: nothing recognised the extension, so nothing processed
the file.
If you've ever stared at a page displaying <%= @user.name %> as literal characters, that's this,
and the fix is in the filename rather than in the template.
Writing an engine takes one lambda
The registration API is a single call and the handler can be anything that responds to call.
shouty = ->(template, source) { "ActionView::OutputBuffer.new(#{source.upcase.inspect})" }
ActionView::Template.register_template_handler(:shouty, shouty)
ActionView::Template::Handlers.extensions.include?(:shouty) # => true
Call it with "hello" and it hands back the string
ActionView::OutputBuffer.new("HELLO"), which is Ruby source that Rails will evaluate for every
render of a .shouty template. unregister_template_handler(:shouty) takes it back out again,
which matters if you're doing this in a test.
That's a toy, obviously. But the contract a real engine implements is this one, and it's worth
knowing how small it is before you decide a templating problem needs a gem. A handler that reads
a markdown file and returns "ActionView::OutputBuffer.new(#{Commonmarker.to_html(source).inspect})"
is a complete, working .md view engine, and it compiles once per file rather than once per
request.
What the two suffixes actually do
show.html.erb carries two independent pieces of information and people conflate them constantly.
The erb chooses the handler, from the list above. The html chooses the format, which is a
different system entirely: it's what respond_to matches against, what sets the response content
type, and what decides whether render "sidebar" finds _sidebar.html.erb or _sidebar.json.erb.
Which is why show.erb with no format works, and is a bad idea. It'll be found for any format,
so a JSON request can end up rendering your HTML template. Name both halves.
So which engine should you use
ERB, unless you have a specific reason. It's in the framework, every Rails developer reads it without learning anything, and the generators emit it. Haml and Slim buy terser markup at the cost of a dependency, a different syntax for everyone who joins, and a layer between you and the thing the browser receives.
The interesting case for a custom handler isn't replacing ERB for your views. It's a format that isn't really a template at all: markdown content, a CSV export, a rendering of something that has its own library. Those are the ones where four lines of handler beats a helper called from a view, because the compile happens once.
What this page doesn't cover
No benchmarks. There's no number here comparing ERB to Slim on render time, because the compile step above means the honest version of that measurement is harder than it looks and the difference on a real page is usually dominated by whatever the page is doing with the database.
Nothing here covers template digests and cache keys, how Rails decides a compiled template is
stale, or the resolver that turns render "posts/show" into a file path through the lookup
context. That resolver is where variants, locales and format fallbacks all live, and it's a bigger
subject than the handlers it eventually hands a file to.
Comments
No comments yet. Be the first.