Herb and ReActionView
Rails 8.2 compiles HTML templates through Herb. Poetry's templates are written for that compiler and tested under it, under ReActionView on top of it, and with slots. This page says what Poetry guarantees, how your own components meet the same bar, and where reactive templates and Poetry components meet today.
What Rails 8.2 changes
An application on the 8.2 framework defaults compiles every template in the HTML format through Herb, and every other format through Erubi as before. Herb reads HTML and ERB as one tree, so a template with a structural problem, such as a tag that is never closed, stops compiling and says where. A valid template renders what it rendered before.
# config/application.rb
config.load_defaults 8.2
# What that sets for templates, and how to go back:
config.action_view.erb_implementation = :herb
# config.action_view.erb_implementation = :erubi
Action View depends on the herb gem from 8.2, so your
bundle has it without a line of its own, and
poetry:install adds none there.
What Poetry guarantees
Each line below is a gate in Poetry's own pipeline, so a release that breaks one does not ship.
- Every template compiles. The gems hand Herb every validator it ships, each one fatal, and have the compiled Ruby checked. That is stricter than Rails, which compiles without validators, and stricter than its check task, which adds one.
- The suites pass on Rails main. The unit suite and the behaviour tier run again on the Rails that comes next, in a host that compiles through Herb.
- The suites pass under ReActionView. They run three more times in a host that compiles every template through ReActionView with nothing rescued: as the engine alone, with slots in server mode, and with slots in client mode.
- The markup is the same. A component renders the bytes it renders under Erubi. With slots on it renders the same markup with the slot markers added.
The versions these hold for are Herb 0.11 and ReActionView 0.6. Reactive templates are experimental upstream, and their markers, payload and client may change between releases.
Your components
Rails checks views, Poetry checks components.
bin/rails herb:check walks the view paths, and a
template that sits beside its component lives outside them. Nothing
reaches it before the page that renders the component does.
bin/rails poetry:check compiles those templates the way
the Rails task compiles a view.
bin/rails herb:check # Rails: every view in the view paths
bin/rails poetry:check # Poetry: every template under app/components, and the ones the gems ship
A template Herb refuses is a herb-compile finding with
the reason and the line. It is an error when your application
renders through Herb and a warning until then. Three shapes that
Erubi renders are refused: ERB output in an attribute name, bare
output where an attribute goes, and
tag.attributes anywhere but last in its tag.
Build elements through element_tag
A host that compiles with slots has Action View's own tag helpers
turned into markup ahead of the render. Two shapes do not survive
that today: attributes handed over in a splat are dropped from the
element, and a tag name held in a variable leaves Ruby that does not
parse. element_tag is a helper Herb does not resolve,
so the element is built at render time from what your component
handed it. It takes what content_tag takes and renders
the same bytes.
<%# app/components/pill/component.html.erb %>
<%= element_tag(root_tag, **root_attributes) do %>
<span <%= tag.attributes(element_attributes(:label)) %>><%= label %></span>
<%= content %>
<% end %>
tag.attributes takes its hash as an argument, never as
a splat. poetry:check holds a component's template to
both with the element-tag warning, and names the call
to write instead.
One name to keep off a component
Rails reads format off what it renders and takes the
answer for the format of the template. An option, a style or a slot
named format defines that reader, and it answers with
the declared value, so a render through the renderer raises. A render
from a controller has always gone through it. From Rails 8.2 a
render in a view goes through it too, which is every helper call on
the page. poetry:check reports the declaration as
renderable-reader, a warning before 8.2 and an error
from it. Keep the keyword for your callers, read the value under
another name, and answer Rails yourself with
define_method(:format) { nil } beside the declaration.
ReActionView, setting by setting
Poetry's templates come from a gem, which ReActionView treats apart from your own. This is what each setting means for them.
# config/initializers/reactionview.rb
ReActionView.configure do |config|
config.intercept_erb = true
config.slots = true
end
| Setting | What it does | Poetry components |
|---|---|---|
intercept_erb |
Compiles HTML templates through ReActionView | Render the same |
external_template_mode |
How templates from gems compile: :fallback, :skip or :compile |
Compile in all three. Poetry tests with :compile, where nothing is rescued |
validation_mode |
What a validator's finding does in your templates | Nothing to report: the templates pass every validator |
slots |
Turns reactive templates on and compiles every template with markers, the ones from gems included | Render the same markup with the markers added |
debug_mode |
Wraps what your own templates output in a span, for the dev tools | Left alone. A component of your own is wrapped, see below |
dev_server, instrumentation |
Development tooling | No effect |
Debug mode and sibling selectors
The span debug mode puts around an output has
display: contents, so the layout holds, but it sits
between two elements that were siblings. A selector that reads a
sibling, such as Tailwind's peer-* variants, stops
matching across it. Poetry's own templates are not wrapped. A
component of yours is, so keep an element and the sibling that
styles itself on it in the same literal markup, or look at the page
with debug mode off before you take a broken state for a bug of
your own.
Markers in your tests
With slots on, the markup a component renders carries comments and
a data-herb-slot attribute. A test that compares
markup compares what the template wrote:
test "the pill renders its label" do
html = render_inline(Pill::Component.new(label: "New")).to_html
assert_equal %(<span data-slot="pill"><span>New</span></span>),
Poetry::Core::SlotMarkers.strip(html)
end
The markers name each template by its path, a gem's install path included. ReActionView 0.6 has no setting for that.
Reactive templates with Poetry components
A reactive template keeps state in the browser and lets the server answer what only it can. One fact decides how Poetry fits in: a Poetry component is rendered by the server. The browser cannot run the helper, so it cannot build a component, and it cannot change what a component's block reads. Everything below follows from that. Each line was walked in a browser on the versions named above.
A form that adds a row
Poetry components work in the form, in a plain
form and in form_with. The row appears
when the form is sent, takes its real key when the server answers,
and what the user types in the meantime stays where it is.
<%# herb:slots client %>
<%# herb:state (composing: true) %>
<ul data-herb-name="messages">
<% @messages.each do |message| %>
<%# herb:key message.id %>
<%# herb:state (pending: false, failed: false) %>
<li>
<strong data-herb-name="author"><%= message.author %></strong>
<span data-herb-name="body"><%= message.body %></span>
<% if pending %><em>sending</em><% end %>
<% if failed %><em>not sent</em><% end %>
</li>
<% end %>
</ul>
<%= form_with url: messages_path, data: { herb_into: "messages" } do |form| %>
<%= poetry_input(name: "message[body]", autocomplete: "off") %>
<%= poetry_button(type: :submit) { "Send" } %>
<% end %>
-
Declare a state. On ReActionView 0.6 a form with
data-herb-intosends nothing from a template that declares none. - Keep what the row shows at once in plain markup. A component in the row arrives with the server's answer. A row made of components alone is empty until then.
-
Keep a branch that follows a state in plain markup.
With a component in it the browser asks the server after every
send. The answer replaces the components on the page, so text
half typed into a
poetry_inputis lost, and it drops a row that was never saved. -
Keep a partial out of
form_with. The server's answer fails when one is rendered inside the block.
A toggle
Any data-herb-* action goes through
data: on a Poetry helper. Read the state beside the
component: a read inside a component's block does not follow it.
<%# locals: (starts:) %>
<%# herb:slots client %>
<%# herb:state (favorite: starts) %>
<%= poetry_button(variant: :outline, data: { herb_toggle: "favorite" }) { "Toggle" } %>
<span><% if favorite %>Favorite<% else %>Not a favorite<% end %></span>
A change the server may refuse
A reactive template flips a toggle at once and leaves it flipped when the server says no. Nothing puts it back. For a change that can be refused, poetry_optimistic_form paints the prediction and answers a refusal with a refresh that restores what the server holds.
Beside Stimulus and Turbo
Herb's client and Poetry's controllers share a page. The client
indexes what Turbo brings in, and a controller of yours can read and
write a template's state with useState from
reactionview. Poetry's own controllers do not call it:
their state is the attributes on the element, where the check, the
server and your CSS can see it.
Herb has deferred content of its own, which needs no endpoint. poetry_deferred loads a Turbo Frame from a URL and shows an error state with a retry. The two do not get in each other's way.
Open upstream, on 2026-09-29
What this page works around, and where each one is tracked.
| Issue | What happens | What Poetry does |
|---|---|---|
| herb 2737 | Attributes handed to a tag helper in a splat are dropped under slots | Templates use element_tag |
| herb 2738 | A tag name held in a variable stops the template compiling under slots | Templates use element_tag |
| herb 2708 | The linter reads a form inside a block helper as a form inside a form | The rule is off in the .herb.yml that poetry:editor writes |
| herb 1111 | Debug mode's wrappers break sibling selectors | Its own templates are not wrapped |
| reactionview 185 | Slots compile templates from gems, and one failure passes the fallback by | Its templates compile with slots |
| reactionview 179 | A partial inside a block helper fails the server's answer | Nothing: keep the partial out of the block |
| reactionview 180 | A server answer replaces markup that changed on the page | Nothing: keep state branches in plain markup |
| view_component 2717 | herb:check does not reach templates beside components |
poetry:check compiles them |