Skip to content

Engine: Introduce SlotVisitor for slot markers - #1986

Merged
marcoroth merged 18 commits into
mainfrom
slot-visitor
Aug 12, 2026
Merged

marcoroth merged 18 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 32d62aa


✅ Preview deployment has been cleaned up.

@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: 32d62aa

@github-actions github-actions Bot added typescript TypeScript source across the javascript/ packages dev-tools @herb-tools/dev-tools visual debugging for HTML+ERB templates labels Aug 12, 2026
Ports the slots feature onto the refactored engine:

- SlotVisitor registers through VisitorStack instead of being prepended to a
  plain Array, and stays ahead of any visitor that rewrites ERB source while
  leaving an inlining visitor first.
- The iteration_nodes parser option is declared on SlotVisitor as a
  recommended_parser_option, so Herb::Visitor.parser_options_for derives it
  rather than the engine setting it by hand.
- SlotVisitor takes its paths from the engine's VisitorContext.
@marcoroth
marcoroth marked this pull request as ready for review August 12, 2026 03:21
Comment thread lib/herb/engine/slot_visitor.rb Fixed
@marcoroth
marcoroth merged commit 055c71a into main Aug 12, 2026
35 checks passed
@marcoroth
marcoroth deleted the slot-visitor branch August 12, 2026 19:35
@marcoroth marcoroth added reactivity Reactive ERB templates: diff and re-render only what changed and removed dev-tools @herb-tools/dev-tools visual debugging for HTML+ERB templates labels Aug 12, 2026
marcoroth added a commit that referenced this pull request Aug 15, 2026
)

This pull request introduces `Herb::Engine::SubtreeCompiler`, which
compiles a template into Ruby that renders one part of it instead of all
of it.

Re-rendering a region means running the template again and keeping only
the piece that changed. The piece cannot be compiled on its own, because
what it renders depends on everything the template did before reaching
it.

```ruby
require "herb/engine/subtree_compiler"

source = Herb::Engine::SubtreeCompiler.new(template, node_path: [4]).src
view.instance_eval(source)
#=> "<ul><li>a-assigned</li><li>b-assigned</li></ul>"
```

#### Everything runs, only the target is kept

A local assigned earlier, a helper called for its side effect, the loop
the target sits inside, all of it has to run for the target to render
the same as it would have in a full render. So nothing is skipped.

```erb
<% total = track("assigned") %>

<div><%= track(@title) %></div>

<ul><li><%= total %></li></ul>
```

Asking for the `<ul>` returns `<ul><li>assigned</li></ul>`. The
assignment above it ran, both tracked calls ran, and the `<div>`
rendered into a buffer that is thrown away.

What changes is where the output goes. The engine writes every append
through one buffer variable, so the target's output is collected by
pointing that variable at the buffer being returned while the target
renders and at a sink the rest of the time. Escaping, blocks, and
control flow then need no special handling, because none of them know
which buffer they are writing to. That is also why the escaping is right
without any work, since the target is going back to the place it was cut
from.

Pruning the work that only fed discarded output is a separate question,
and answering it needs to know which expressions the target actually
depends on. Running everything is the answer that is correct without
that analysis.

#### Control flow around the target

A target inside a loop renders once per iteration, and a target inside a
branch that did not run renders nothing.

```erb
<% @names.each do |name| %>
  <li><%= name %></li>
<% end %>
```

Asking for the `<li>` with two items returns `<li>a</li><li>b</li>`,
because the loop it sits in is part of what has to run.

#### Addressing

The target is named by `node_path`, the same path `SlotVisitor` records
for a slot in [#1986](#1986) and
`Herb::ActionView::TemplateDependencies` reports for a node, so a caller
holding one from either has one that works here. An empty path is the
whole document, which is tested for output identity against
`Herb::Engine` itself. A path that leads nowhere raises `TargetNotFound`
instead of compiling a template that silently renders nothing.

This branch was successfully deployed

1 active deployment
herb-tools (Preview) — 32d62aa3 Deployed Aug 12, 2026 by github-actions[bot]
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/ reactivity Reactive ERB templates: diff and re-render only what changed ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging typescript TypeScript source across the javascript/ packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants