Skip to content

Engine: Introduce SlotSubtree to render the markup a slot covers - #2283

Merged
marcoroth merged 1 commit into
slots/statefrom
slots/subtrees
Aug 18, 2026
Merged

Engine: Introduce SlotSubtree to render the markup a slot covers #2283
marcoroth merged 1 commit into
slots/statefrom
slots/subtrees

Conversation

@marcoroth

@marcoroth marcoroth commented Aug 18, 2026

Copy link
Copy Markdown
Owner

This pull request introduces Herb::Engine::SlotSubtree, which compiles the markup behind one slot, for the two cases a payload of values cannot carry.

SlotIndex#apply reports a deferred entry when the page is asked for something values cannot express, which is a conditional taking a branch that never rendered, and a collection gaining an item there is no row to copy. Both name a slot, and nothing on the server answered one.

subtree = Herb::Engine::SlotSubtree.new(source, filename: "app/views/posts/index.html.erb")

view.instance_eval(subtree.source_for(0))
#=> "<li>one</li><li>two</li>"

SubtreeCompiler renders the node at a node_path and SlotVisitor records the node_path of every slot, so a slot index is all that is needed to reach its markup. The two agreeing on what a node_path means is what makes this a lookup.

An attribute is refused

A path indexes an element's body and does not descend into its open tag, so an attribute slot's path names the element holding it. Compiling that would hand back <div class="card">x</div> where the caller asked for card. Those slots are values, and values is how they come back.

Asking for only what changed

subtree_slots says which slots a change needs markup for, so a request naming what changed can be answered without asking the page.

dependencies.subtree_slots("app/views/posts/index.html.erb", ["@admin"])
#=> [{ file: ".../index.html.erb", version: "a1b2c3d4", index: 0, mode: :structural }]

Only structural slots appear, because that is the whole of what values cannot say. Changing something a branch merely displays asks for nothing.

What this does not do

It does not make the render cheaper. SubtreeCompiler runs the whole template and keeps one node's output, which its own documentation says is the answer that is correct without knowing which expressions the target depends on:

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.

That analysis now exists, so the question can be asked. It is not asked here, because answering it means changing what the compiler emits and taking on the side effects that come with skipping work.

@nx-cloud

nx-cloud Bot commented Aug 18, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 626c080

Command Status Duration Result
nx run-many -t test --all --parallel --exclude=... ✅ Succeeded 3m 18s View ↗
nx run-many -t build --all --exclude=herb-langu... ✅ Succeeded 38s View ↗
nx build @herb-tools/tailwind-class-sorter ✅ Succeeded 1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-08-18 16:29:11 UTC

@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 18, 2026
@github-actions

github-actions Bot commented Aug 18, 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 7f8444b


✅ Preview deployment has been cleaned up.

@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

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

commit: 7f8444b

@marcoroth marcoroth added the reactivity Reactive ERB templates: diff and re-render only what changed label Aug 18, 2026
@marcoroth
marcoroth merged commit d36e19f into main Aug 18, 2026
56 of 65 checks passed
@marcoroth
marcoroth deleted the slots/subtrees branch August 18, 2026 17:05
marcoroth added a commit that referenced this pull request Aug 19, 2026
This pull request teaches the dependency analysis that a block's
parameters carry whatever that block iterates, in one template and
across a `render` call, and in all three languages that implement it.

```erb
<ul>
  <% @items.each do |item| %>
    <li><%= item.name %></li>
  <% end %>
</ul>
```

`item` is `@items`, one at a time. Matching is by name, so nothing
connected the two, and `item.name` was attributed to nothing.

Ruby learned this within a single template in #2280. This finishes it:
the two ports learn the same thing, and the Ruby trace learns it across
a render boundary, which is where it was answering a user-facing
question incorrectly.

`affected_templates` and `state_flow` back `herb dependencies` and `herb
actionview flow`, and their whole product is the list of templates a
piece of state reaches. One shape was missing from it.

traced:
```erb
<%= render partial: "posts/card", collection: @posts %>
```

not traced:
```erb
<% @posts.each do |post| %>
  <%= render "posts/card", card: post %>
<% end %>
```

For the second, `affected_templates(entry, "@posts")` returned
`["index.html.erb"]` and omitted `_card.html.erb`, and `state_flow`
returned a tree with no child. `trace_state` asks whether a render
call's local carries the state, and `card: post` names a block parameter
that nothing tied to the collection that bound it.

The collector now records the blocks open around a render call, with the
names each binds, and a local whose value is one of those names carries
what its block iterates.

`analyze` now parses with `iteration_nodes`, so a block running per item
and a block running once are different node types. Without that,
`form_with model: @post do |f|` looks like a collection and `f` carries
`@post` into every partial rendered inside the form.

Once the trace reaches `_card.html.erb`, its slots become addressable,
and they came back as an `identity`, which is a client writing one value
into every card. A render inside an iterating block is now per item the
same way `collection:` is, and the modes below a block that runs once
are left alone.

Follow up on #2279, #2280, #2281, #2282 and #2283.
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant