Skip to content

Engine: Introduce SlotVisitor for slot markers - #1986

Draft
marcoroth wants to merge 4 commits into
mainfrom
slot-visitor
Draft

Engine: Introduce SlotVisitor for slot markers#1986
marcoroth wants to merge 4 commits into
mainfrom
slot-visitor

Conversation

@marcoroth

Copy link
Copy Markdown
Owner

This pull request introduces Herb::Engine::SlotVisitor, which assigns a stable index to every dynamic insertion point in a template and delimits it in the compiled output, so that a consumer can address a specific part of the rendered result without scanning the DOM for matching content.

Motivation

Herb can already tell what changed between two syntax trees (#1518) and which templates and nodes a piece of state reaches (#1667). What has been missing is a way to find the corresponding position in the rendered output. Diff operations carry a path into the template tree, but an ERB node occupies one index there while producing any number of nodes in the browser, so those paths do not survive rendering. The dev server works around this today by matching on content, which is ambiguous whenever the same text or attribute value appears twice.

A slot marker gives that position a name. Because indices are assigned in document order at compile time and recorded against the same node_path that Herb::ActionView::TemplateDependencies reports, a state lookup and a marker in the output refer to the same thing by construction.

Slots

A slot is one dynamic insertion point, typed after the Part taxonomy from the DOM Templating API proposal:

Type From
child <%= %> output and <%= yield %> in child position
conditional if / unless / case, counting an elsif / else chain as one slot
collection ERBIterationBlockNode, so a repeating region is told apart from a block that merely wraps its body
attribute ERB inside an attribute value
block any other block, such as form_with do or @user.tap do

Enabling slots turns on the iteration_nodes parser option, which is what makes the collection distinction possible. Without it @users.each do |user| and form_with model: @user do |form| are both an opaque ERBBlockNode, and keyed reconciliation needs the stronger fact.

Markers

Slots are delimited with HTML comments:

<p><%= @name %></p>
<!--herb-region:app/views/test.html.erb:3877ae64--><p><!--herb-slot:0-->Marco<!--/herb-slot:0--></p><!--/herb-region:app/views/test.html.erb-->

Comments rather than wrapper elements, because a comment is legal in <head> and inside SVG and is invisible to CSS sibling combinators and Tailwind peer-* variants. DebugVisitor wraps ERB output in <span style="display: contents"> for the same purpose, which is the cause of a long tail of layout problems (marcoroth/reactionview#49, marcoroth/reactionview#98, marcoroth/reactionview#103, #1810, #1111, #1052). No marker strategy here introduces an element, and the test suite asserts that.

An HTML comment cannot sit inside a tag, so attribute slots are anchored on the enclosing element instead:

<div class="<%= @klass %>"></div>
<div class="card" data-herb-slot="0"></div>

A conditional that renders nothing still leaves its position behind:

<div><% if @admin %><b>secret</b><% end %></div>
<div><!--herb-slot:0--><!--/herb-slot:0--></div>

The client learns that a position exists without learning what would fill it. Nested slots become addressable once their parent branch renders, so <!--herb-slot:1--> above appears only when @admin is true.

The marker syntax lives behind a SlotMarkers object so it can be swapped for the native range markers from Chrome's declarative partial updates (<?start name="..."> / <?end>) once those are unflagged. Those parse into comment nodes through the HTML parser's bogus-comment state, so the migration is a change of spelling rather than of node type.

Schema

Each template gets a schema: the ordered list of slot indices and types, plus a version hash over that layout. The version covers structure, not content, so editing an expression keeps the same slot index while adding or removing an ERB tag changes the hash. That is what lets a consumer detect a template whose layout it no longer matches.

engine = Herb::Engine.new(source, slots: true, filename: "app/views/posts/show.html.erb")

engine.slot_visitor.version
# => "3877ae64"

engine.slot_visitor.schema
# => { file: "app/views/posts/show.html.erb",
#      version: "3877ae64",
#      slots: [{ index: 0, type: :child, node_path: [0, 0] }] }

engine.slot_visitor.slots.first.expression
# => "@name"

Usage

The visitor is off by default and enabled with the slots engine option, mirroring debug:

Herb::Engine.new(source, slots: true, filename: "app/views/posts/show.html.erb")

It runs ahead of the debug visitor so that slots are assigned against the template as written rather than against the wrapper elements debug mode injects.

Related #1518, #1667, #1912.

@github-actions github-actions Bot added ruby Ruby source for the gem and its libraries rbs RBS type signatures in sig/ engine Herb engine and Rails template compilation rubygem The herb RubyGem and its packaging labels Aug 3, 2026
@marcoroth marcoroth changed the title Engine: Introduce SlotVisitor for slot markers Engine: Introduce SlotVisitor for slot markers Aug 3, 2026
@marcoroth marcoroth added this to the v0.11.0 milestone Aug 3, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

馃尶 Interactive Playground and Documentation Preview

A preview deployment has been built for this pull request. Try out the changes live in the interactive playground:


馃尡 Grown from commit f56fb26

@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown
npx https://pkg.pr.new/@herb-tools/formatter@1986
npx https://pkg.pr.new/@herb-tools/language-server@1986
npx https://pkg.pr.new/@herb-tools/linter@1986

commit: f56fb26

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

engine Herb engine and Rails template compilation rbs RBS type signatures in sig/ ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant