Analysis: Introduce SlotDependencies to say which state a slot reads - #2280
Merged
Conversation
|
View your CI Pipeline Execution ↗ for commit ea56bf5
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗ ☁️ Nx Cloud last updated this comment at |
🌿 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
slots/subscriptions
branch
from
August 18, 2026 16:17
382154a to
ea56bf5
Compare
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.
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::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_pathmeans, so the join is a lookup.A mode says who can produce the next value.
identity<%= query %>orvalue="<%= query %>". A client writes it by copying.structuralderivedThree things had to be true first
A collection's body was attributed to nothing.
@items.each do |item|bindsitem, 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
@postno 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:andherb dependencieskeeps 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 thecardthe template wrote. That is the same refusal the client already makes withpartial-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.