Repository navigation
Engine: Introduce SlotVisitor for slot markers - #1986
Merged
Merged
Conversation
SlotVisitor for slot markers
🌿 Interactive Playground and Documentation PreviewA preview deployment has been built for this pull request. Try out the changes live in the interactive playground: 🌱 Grown from commit ✅ Preview deployment has been cleaned up. |
commit: |
marcoroth
force-pushed
the
slot-visitor
branch
from
August 12, 2026 02:45
f56fb26 to
82787f2
Compare
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
force-pushed
the
slot-visitor
branch
from
August 12, 2026 03:20
82787f2 to
1516671
Compare
marcoroth
marked this pull request as ready for review
August 12, 2026 03:21
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_paththatHerb::ActionView::TemplateDependenciesreports, 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:
child<%= %>output and<%= yield %>in child positionconditionalif/unless/case, counting anelsif/elsechain as one slotcollectionERBIterationBlockNode, so a repeating region is told apart from a block that merely wraps its bodyattributeblockform_with door@user.tap doEnabling
slotsturns on theiteration_nodesparser option, which is what makes thecollectiondistinction possible. Without it@users.each do |user|andform_with model: @user do |form|are both an opaqueERBBlockNode, and keyed reconciliation needs the stronger fact.Markers
Slots are delimited with HTML comments:
Comments rather than wrapper elements, because a comment is legal in
<head>and inside SVG and is invisible to CSS sibling combinators and Tailwindpeer-*variants.DebugVisitorwraps 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:
A conditional that renders nothing still leaves its position behind:
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@adminis true.The marker syntax lives behind a
SlotMarkersobject 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.
Usage
The visitor is off by default and enabled with the
slotsengine option, mirroringdebug: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.