Skip to content

Analysis: Carry a page's state into the partials that render it - #2281

Merged
marcoroth merged 1 commit into
slots/subscriptionsfrom
slots/propagation
Aug 18, 2026
Merged

Analysis: Carry a page's state into the partials that render it#2281
marcoroth merged 1 commit into
slots/subscriptionsfrom
slots/propagation

Conversation

@marcoroth

@marcoroth marcoroth commented Aug 18, 2026

Copy link
Copy Markdown
Owner

This pull request adds SlotDependencies#across, which reports every slot a page's state reaches, under the name the page knows that state by.

A slot map in one template's vocabulary cannot answer what a page changing @query should update, because the partial it renders knows that state as term. trace_state already renames state at each render call, so the walk follows it and the entry point's name is the one that comes back out.

Herb::Engine::SlotDependencies.new(project_path).across("app/views/posts/index.html.erb")
#=> { "@query" => [
#      { file: ".../index.html.erb",   version: "f6775c02", index: 0, mode: :identity },
#      { file: ".../_search.html.erb", version: "edd6ae13", index: 0, mode: :identity },
#      { file: ".../_search.html.erb", version: "edd6ae13", index: 1, mode: :derived },
#    ] }

A partial's incoming locals are not among the names it declares unless it declares them, so the names to look for come from the trace and not from the file. Reading a partial in its caller's terms is what makes the same template answer differently for two callers, which is also why the result is computed per set of carried names instead of once per file.

Every slot carries the version of the template it came from, since a page is built from several and a partial's version moves without its caller's.

A collection's item template is left to the server

A page names its state once. A template rendered for each of a collection's items renders once per item, so that name says nothing about which of them is meant, and a client writing the slot from the page would put the same value into every one of them.

render partial: "posts/card", collection: @posts reported the item template's slots as an identity, which is the one reading that is unsafe. Crossing a collection now downgrades an identity to derived, for that template and everything it renders in turn.

Rendering a partial inside an each block is the other shape this takes and needs nothing here. The trace does not follow a block parameter across a render call, so the item template is never reached and its slots subscribe to nothing. That is a missing subscription and not a wrong write, and a slot nobody wrote asks the server like any other.

Matching a render call to the template it reached is by name, so two partials sharing one under different directories both count as a collection's. That errs towards the server, which is the direction that cannot be wrong.

Delivery

The map describes a render tree and not one rendering of one template, so it names the file and version of every slot it lists and travels once for the page, parked the way statics are:

<template data-herb-dependencies>{"state":{"@query":[{"file":"app/views/posts/index.html.erb","version":"f6775c02","index":0,"mode":"identity"}]}}</template>

@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
@nx-cloud

nx-cloud Bot commented Aug 18, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 154b999

Command Status Duration Result
nx run-many -t test --all --parallel --exclude=... ✅ Succeeded 3m 42s View ↗
nx run-many -t build --all --exclude=herb-langu... ✅ Succeeded 40s 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:35 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 5f0ec41


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

commit: 5f0ec41

@marcoroth marcoroth added the reactivity Reactive ERB templates: diff and re-render only what changed label Aug 18, 2026
@marcoroth
marcoroth merged commit cd37334 into main Aug 18, 2026
39 of 46 checks passed
@marcoroth
marcoroth deleted the slots/propagation 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.
marcoroth added a commit that referenced this pull request Aug 19, 2026
This pull request fixes a slot dependency map that told a client it
could write a value the client has never been given.

`SlotDependencies` classifies each slot by where its next value comes
from. `identity` means the slot's expression is the state itself, so a
client writes it by copying what it already holds. Everything computed
is `derived`, and only the server can answer it.

A constant is a dependency and not something a page can set, so it can
never be an identity. `for` had that right and `across` did not, and
`across` is the one whose answer is delivered:

```ruby
subject.for(path)      #=> { 0 => { state: ["Post.count"], mode: :derived } }
subject.across(path)   #=> { "Post.count" => [{ …, mode: :identity }] }
subject.payload(path)  #=> { "state" => { "Post.count" => [{ "mode" => "identity" }] } }
```

A partial's incoming locals are not among the names it declares, so
#2281 treats the names the trace carries as settable, which is what lets
a partial answer in its caller's terms. Constants arrive through the
same door and are settable by nothing, so the exclusion `for` applies
was lost on the way.

The map already leaves constants out of the request names it publishes,
so `payload["params"]` is empty for a template whose only state is
`Post.count`, and nothing resolves to it by the name a request would
use. Reaching it takes calling `state.set("Post.count", …)` by the
state's own name, through the escape hatch meant for state whose request
name could not be derived.
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