Skip to content

Analysis: Introduce SlotDependencies to say which state a slot reads - #2280

Merged
marcoroth merged 1 commit into
slots/node-pathfrom
slots/subscriptions
Aug 18, 2026
Merged

Analysis: Introduce SlotDependencies to say which state a slot reads#2280
marcoroth merged 1 commit into
slots/node-pathfrom
slots/subscriptions

Conversation

@marcoroth

@marcoroth marcoroth commented Aug 18, 2026

Copy link
Copy Markdown
Owner

This pull request introduces Herb::Engine::SlotDependencies, which answers, for each of a template's slots, which state it reads and where its next value can come from.

A slot knows where it is and the dependency graph knows what a state reaches, but nothing joined them, so a caller holding both still could not answer the only question that matters when state changes, which is what this touches. The two now agree on what a node_path means, so the join is a lookup.

Herb::Engine::SlotDependencies.new(project_path).for("app/views/posts/index.html.erb")
#=> { 0 => { state: ["@items"], mode: :structural },
#     1 => { state: ["@items"], mode: :derived } }

A mode says who can produce the next value.

Mode Meaning
identity The expression is the state itself, as in <%= query %> or value="<%= query %>". A client writes it by copying.
structural A conditional or a collection. A client rebuilds it only from markup the server parked.
derived An expression that has to be evaluated, and evaluating it means running Ruby on the server.

Three things had to be true first

A collection's body was attributed to nothing. @items.each do |item| binds item, and the matching was textual, so every slot inside the loop subscribed to nothing, which is where slots are densest. Block parameters now alias the state their block iterates, for as long as that block is open.

Matching an instance variable now ends on a word boundary, so @post no longer answers for @posts. An instance variable already begins on a boundary, since a name cannot run into the sigil, so only the end of it needs checking.

A conditional is reported against its own condition instead of everything inside it. Otherwise every state in a branch would also wake the branch holding it, and the map would say everything depends on everything. That is a second reading of the same question, so it is opt-in through conditions_only: and herb dependencies keeps the one it had.

Two classifications that look wrong until they are not

An interpolated attribute is never an identity. class="card <%= @state %>" reads @state, but a marker names the attribute and not the stretch of it, so writing the value would drop the card the template wrote. That is the same refusal the client already makes with partial-attribute, now agreed with at compile time.

A constant is never an identity. <%= Time.now %> is a real dependency, but not one a page can set, so there is nothing for a client to copy. Only instance variables and declared locals can be identities.

@github-actions github-actions Bot added ruby Ruby source for the gem and its libraries typescript TypeScript source across the javascript/ packages rbs RBS type signatures in sig/ engine Herb engine and Rails template compilation rubygem The herb RubyGem and its packaging rust Rust bindings and the Herb Rust crate analysis labels Aug 18, 2026
@nx-cloud

nx-cloud Bot commented Aug 18, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit ea56bf5

Command Status Duration Result
nx run-many -t test --all --parallel --exclude=... ✅ Succeeded 4m 8s View ↗
nx run-many -t build --all --exclude=herb-langu... ✅ Succeeded 18s 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:25:42 UTC

@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 ea56bf5


✅ 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@2280
npx https://pkg.pr.new/@herb-tools/language-server@2280
npx https://pkg.pr.new/@herb-tools/linter@2280

commit: ea56bf5

@marcoroth marcoroth added the reactivity Reactive ERB templates: diff and re-render only what changed label Aug 18, 2026
@marcoroth
marcoroth force-pushed the slots/subscriptions branch from 382154a to ea56bf5 Compare August 18, 2026 16:17
@marcoroth
marcoroth merged commit f1797ff into main Aug 18, 2026
62 of 79 checks passed
@marcoroth
marcoroth deleted the slots/subscriptions branch August 18, 2026 17:05
marcoroth added a commit that referenced this pull request Aug 18, 2026
A block's parameters carry whatever that block iterates, so `item` inside
`@items.each do |item|` is `@items`, one at a time. Matching is by name, so
nothing connected the two, and everything reached through a parameter was
attributed to nothing. Ruby learned this within one template in #2280. The two
ports learn it here, and the Ruby trace learns it across a render call.

`affected_templates` and `state_flow` answer which templates a piece of state
reaches, and they missed one shape of it. A partial rendered by
`render partial:, collection:` was traced, and the same partial rendered from
inside `@posts.each do |post|` was not, so `herb dependencies` and
`herb actionview flow` left a template out of an answer whose whole product is
that list. The collector now records the blocks open around a render call, with
the names each one binds, and a local whose value is one of those names carries
whatever its block iterates.

`analyze` parses with `iteration_nodes` so a block that runs per item and a
block that runs once are different node types. Without it, `form_with model:
@post do |f|` would look like a collection and `f` would carry `@post` into
every partial rendered inside the form. The C extension has always accepted the
option; only its hand written signature did not mention it.

Reaching the item template makes it addressable, and addressable is what made
it wrong: its slots came back as an identity, which is a client writing one
value into every item. A render inside an iterating block is now per item the
same way `collection:` is, and only the modes below a block that runs once are
left alone.

Neither port needed a new dependency or the trick Ruby uses. Ruby re-parses the
block opening as `Prism.parse("#{code}\nend")`, because `@posts.each do |post|`
is not valid Ruby on its own, but both ports already carry the parameters on the
node as `block_arguments`. Rust had two further gaps in the way: it never
recorded a block, because its kind match had no case for one, and it could not
have read the expression anyway, because `content_of` had no case either.

All three now assert the two nodes Ruby reports for the same template, kind,
path and expression alike. Nothing else anywhere asserts that they agree, and
every divergence found so far was found by reading two implementations side by
side.

This was filed as a granularity optimisation to defer until a benchmark
justified it. That was wrong. It is coarser for reactivity, where under-reporting
waits for the server, and simply incorrect for the analysis commands, where the
list is the answer.
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

analysis 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 rust Rust bindings and the Herb Rust crate typescript TypeScript source across the javascript/ packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant