Skip to content

Engine: Bind a partial's states to its caller through state: on render - #2651

Merged
marcoroth merged 1 commit into
render-state-keywordfrom
render-state-bindings
Sep 14, 2026
Merged

marcoroth merged 1 commit into
render-state-keywordfrom
render-state-bindings

Conversation

@marcoroth

@marcoroth marcoroth commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

This pull request gives state: on a render call its meaning, at compile time and at render time. #2650 taught the parser to read the argument, #2649 gave the engine a resolver that can find the partial it names, and #2652 aliases the two scopes on the client.

<%# herb:state (open: false, label: "") %>          # in shared/_album_card.html.erb

<%= render "shared/album_card", album: a, state: { open: modal_open, label: "featured" } %>

The partial keeps declaring its states the way it always has, defaults included, so it runs standalone anywhere. The render site decides what to wire. The parser already hands the compile one declaration node per entry, with the kind it would give a herb:state default, and the compile classifies each one. A bare value that names one of the caller's states is a binding, a two-way alias the client will keep in step, and anything else is a seed, an initial value the partial owns from then on. That mirrors how a declaration default is classified, a literal is typed and an expression is seeded. Nothing parses the Ruby a second time, and every diagnostic points at the entry it is about.

The argument never reaches ActionView. The compile strips it out of the call by the location the parser recorded for it, comma included on whichever side carried it, before the slot records its expression, so the child slot is not mistaken for a server read of modal_open. After the state checks pass it wraps the call:

::Herb::Engine::Slots::Bindings.with("app/views/shared/_album_card.html.erb", bound: { "open" => modal_open }, seeded: { "label" => ("featured") }) { render "shared/album_card", album: a }

Bindings keeps a fiber-local stack of frames, and StateOverrides.resolve layers a frame with the values a client sent. The order is the compiled default, then seeds, then the client's values, then bindings. A seed sits under the client because a client that has written the state since knows better, otherwise every refetch would reset it. A binding sits over the client because the Herb-State header dedupes per template and cannot tell two render sites apart, while the wrapper re-runs with the caller's steered value on every values render.

Two things had to change for the frames to reach a page render. The fetch-based state prelude used to compile only into the values program, so the HTML render of a partial had no hook a caller could feed. It now compiles into every template that declares states, which is the one visible change for existing compiled snapshots. And the seeds marker used to ship only statically seeded declarations, so a literal default like open: false seeded to true would render true on the server while the client fell back to false. The marker now also carries any state whose value came from a frame or the header.

Bindings are checked on both ends. The caller's side refuses a **splat or a non-symbol key, since state: spells every binding out and a splat hides which states it binds, along with a duplicate key, a name that goes to the partial both as a local and as a state (the state assignment would silently overwrite the local otherwise), a block render, a render inside an attribute or comment, a caller state that is derived or counted, and any state: on a render that picks its template at runtime, since a binding only makes sense where the compiler can see the callee. Each of these points at the entry it is about instead of at the whole render.

The callee's side needs a partial it can open, so the name has to be a literal that carries its directory, shared/album_card and not album_card, since a bare name resolves through the controller's view paths and inheritance at render and the compiler cannot follow that. It also has to exist, with a did-you-mean over the project's partials when it does not. Both answers come from context.resolver, so an application that registers its own lookup decides which file a binding targets and what identifier it carries, and the manifest agrees with the partial's own compile by construction. The partial is then compiled, memoized by path and modification time with a guard against render cycles, and the check refuses a key the partial never declares, a derived or counted partial state, and a kind mismatch between a bound or literally seeded value and the partial's declaration, reusing the compatibility rule the state directives already apply. A float, array or hash literal is refused outright for the same reasons a declaration default of that kind is, so state: { tags: [1, 2] } fails the compile with the advice to pass it as a local. A partial that does not compile fails the caller with the partial's own diagnostic, so the error points at the file that has it. An uncoercible seed value at render, an ActiveRecord relation reaching a state through an expression for instance, raises at the call site, since it is the author's mistake and not client input.

The manifest gains a bindings table keyed by the child slot index, next to parts, naming the partial's identifier and the child-to-parent state map, and a binding churns the caller's version. Identifiers go through the same strategy the partial's own compile uses, so path and digest modes agree by construction. A render that is an element's only child keeps its comment markers instead of folding into the parent's data-herb-slot, so the client can find the enclosing slot.

@marcoroth
marcoroth added this pull request to stack #2653 September 14, 2026 08:56
@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 client-runtime Browser runtime for the Herb slots. labels Sep 14, 2026
@github-actions

github-actions Bot commented Sep 14, 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 c347fa4


✅ Preview deployment has been cleaned up.

@pkg-pr-new

pkg-pr-new Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
@herb-tools/analysis

npx https://pkg.pr.new/@herb-tools/analysis@2651

@herb-tools/browser

npx https://pkg.pr.new/@herb-tools/browser@2651

@herb-tools/client

npx https://pkg.pr.new/@herb-tools/client@2651

@herb-tools/config

npx https://pkg.pr.new/@herb-tools/config@2651

@herb-tools/core

npx https://pkg.pr.new/@herb-tools/core@2651

@herb-tools/dev-tools

npx https://pkg.pr.new/@herb-tools/dev-tools@2651

@herb-tools/formatter

npx https://pkg.pr.new/@herb-tools/formatter@2651

herb-language-server

npx https://pkg.pr.new/herb-language-server@2651

@herb-tools/highlighter

npx https://pkg.pr.new/@herb-tools/highlighter@2651

@herb-tools/language-server

npx https://pkg.pr.new/@herb-tools/language-server@2651

@herb-tools/language-service

npx https://pkg.pr.new/@herb-tools/language-service@2651

@herb-tools/linter

npx https://pkg.pr.new/@herb-tools/linter@2651

@herb-tools/minifier

npx https://pkg.pr.new/@herb-tools/minifier@2651

@herb-tools/node

npx https://pkg.pr.new/@herb-tools/node@2651

@herb-tools/node-wasm

npx https://pkg.pr.new/@herb-tools/node-wasm@2651

@herb-tools/printer

npx https://pkg.pr.new/@herb-tools/printer@2651

@herb-tools/rewriter

npx https://pkg.pr.new/@herb-tools/rewriter@2651

stimulus-lint

npx https://pkg.pr.new/stimulus-lint@2651

@herb-tools/tailwind-class-sorter

npx https://pkg.pr.new/@herb-tools/tailwind-class-sorter@2651

commit: c347fa4

@marcoroth
marcoroth force-pushed the render-state-bindings branch from 9cfc614 to c347fa4 Compare September 14, 2026 16:06
@marcoroth
marcoroth merged commit 1106819 into main Sep 14, 2026
56 of 72 checks passed
@marcoroth
marcoroth deleted the render-state-bindings branch September 14, 2026 16:54
marcoroth added a commit that referenced this pull request Sep 14, 2026
This pull request gives the engine a single place to turn a partial name
into a file, and lets an application replace that place with a lookup
that knows its real view paths.

The engine resolves partials at compile time in more than one spot, the
render validator and the inliner today and the `state:` check in #2651
next, and each walked the filesystem on its own from the project's
single view root. That is a fair approximation of one Rails app with one
view path, and wrong for everything else. Engines and gems register view
paths of their own, `prepend_view_path` reorders them, and a project's
templates need not live under `app/views` at all. ActionView, which is
where these compiles actually run, can answer the question exactly, the
way `ActionView::Digestor` resolves render dependencies for cache
digests, and it also knows how it names the template it finds, which is
what a state binding needs to key on.

`Herb::Analysis::PartialResolver` is the new object. It answers
`resolve(name, from:, format:)` with the file and the identifier the
compile of that file reports for itself, `candidates(name, from:)` with
the places it looked, `similar(name, from:)` with did-you-mean
suggestions, and `identifier_for(path)`. The default carries the
behavior the engine always had. A name with a directory is looked up
under the view root, a bare name next to the calling template and then
under the view root, a format prefers its own extension, and the
identifier is the path relative to the project, which is exactly what
`Context#relative_file_path` reports for that same template.

`Herb::Visitor::Context` exposes it as `context.resolver`, built from
the project path on first use, and `Herb::Engine.new(source, resolver:
...)` hands in a replacement.

```ruby
Herb::Engine.new(source, filename: path, project_path: root, resolver: MyLookup.new)
```

The render validator and the inliner ask the context instead of
computing view roots and source directories themselves, and
`ERBRenderNode#resolve`, `#candidate_paths` and `#similar_partials`
delegate to the same class, so the node keeps its API with one
implementation behind it.

An object standing in for the default needs those four methods and
nothing else. The tests drive the validator through one that resolves
names the filesystem does not have and lists its own candidates in the
diagnostic, and drive the inliner through a resolver rooted somewhere
other than `app/views`.
marcoroth added a commit that referenced this pull request Sep 14, 2026
…es (#2650)

A render call can carry a `state:` hash to bind a partial's declared
states to the caller's, and this pull request teaches the parser to read
it:

```erb
<%= render "shared/album_card", album: a, state: { open: modal_open } %>
```

Every template, partials included, compiles as its own slots region with
its own states, and locals only ever carry a snapshot into a partial.
Making the render site the place that decides whether a partial's state
is wired to the caller's starts here, with the parser. Nothing acts on
the argument yet. #2651 validates it and splices it out at compile, and
#2652 aliases the two scopes on the client.

The parser takes the hash apart the way it already takes `locals:` and a
`herb:state` signature apart. `RubyRenderKeywordsNode` gains a `state`
array with one `HerbStateDeclarationNode` per entry, carrying the name
token, the value as a `RubyLiteralNode`, and the kind the directive
parser gives that value, so `open: modal_open` is `bare`, `count: 3` is
`integer`, `label: "hi"` is `string` and `featured: album.featured?` is
`seeded`. A `state_location` next to it spans the whole `state: { ... }`
argument, which is what lets the engine strip it out of the call by
position without parsing the Ruby again.

```
├── state: (2 items)
│   ├── @ HerbStateDeclarationNode (location: (1:51)-(1:67))
│   │   ├── name: "open" (location: (1:51)-(1:56))
│   │   ├── default_value:
│   │   │   └── @ RubyLiteralNode (location: (1:57)-(1:67))
│   │   │       └── content: "modal_open"
│   │   │
│   │   └── kind: "bare"
│   │
│   └── ...
│
└── state_location: (location: (1:42)-(1:69))
```

Reusing `HerbStateDeclarationNode` is deliberate. A `state:` entry plays
the role a `herb:state` default plays, it is the value the partial's
state starts from or is bound to, and the kind is what the engine needs
to tell a binding from a typed seed. A new node would have meant a visit
arm in every hand-written printer for the same three fields.

The parser fills the fields when the `herb_directives` option is on and
the value is a hash literal. The argument then stays out of the implicit
locals, and it is accepted on the `partial:` form, where any keyword
outside the known render options raised the missing-locals error before.
An element the parser cannot read as a `name: value` pair, a `**splat`
or a string key, is recorded as an entry named after its own source with
the kind `missing`, so the compile can refuse it and point at it.
Deciding whether the hash's contents are usable is semantics and belongs
to #2651, and the parser only answers whether this is the `state:`
keyword carrying a hash.

Both gates are deliberate. `state` is a plausible name for an ordinary
local, so `render "address", state: @address.state` has to keep meaning
what it means today, and it does, because a non-hash value stays a
local. And a parse without herb directives, which is every consumer
outside a slots compile, sees exactly the tree it saw before, `state: {
... }` included. The tests cover each side of both gates and snapshot
the kind of every literal shape.

The three places that read a `name: value` pair off a render call, the
`locals:` hash, the implicit keywords, and now the state entries, share
one helper, and that helper unwraps the hash shorthand. `render "card",
album:` used to spell the local's value as `album:`, since Prism's
implicit node spans the key, and a state entry written that way would
have classified as `seeded`. The value is now the key's name, spanning
it without the colon, and the entry is `bare`.

The only visible change for existing snapshots is the `state: []` and
`state_location: ∅` lines the printer now emits for every render
keywords node.
marcoroth added a commit that referenced this pull request Sep 14, 2026
This pull request makes a bound partial's states and its caller's the
same states on the client. #2651 records the binding in the caller's
manifest and carries the values through a render frame, and this reads
that table back.

A region now records the region, child slot and item it was scanned
inside. That link was never kept before, since the scan pushes the
parent region on its stack right where the nested one opens, and
`placements` could always re-derive it per node, but nothing needed it
persistently until a partial's state had to know whose it is. A
manifest's `bindings` table, keyed by the child slot index next to
`parts`, names the partial and maps each of its bound states to the
caller's.

`resolveState` follows that link. Given a scope and a name it answers
the scope and name that actually hold the value, walking up through as
many bound partials as are nested. `valueAt` resolves first, which makes
every read, steering value, derived state, branch settle and listener
alias-aware in one place. `setState` resolves each name up front and
forwards the ones that live in a caller to that caller's scope, one
recursive write per distinct scope, so a `data-herb-set="open=false"`
inside the partial writes `modal_open` on the page.

A caller's write then propagates back down. Every region bound to the
written scope gets its value slots rewritten through the alias, its
derived states recomputed, its conditionals, presence, computed
attributes and read refetches run against its own manifest, and its own
name announced beside the caller's. Each direction announces exactly
once per name.

Steering needs nothing new. `Refresh#steering` reads through `valueAt`,
so a bound partial's `Herb-State` entry carries the caller's current
value under the partial's own file, and the engine's `Bindings` frame
wins over it on the way back.

An element inside a bound partial now resolves innermost first, so the
partial's own `open` wins over a caller state that happens to share the
name. Nested regions without bindings keep today's outermost-first order
and behave exactly as before, which the tests pin down alongside the
bound cases. They cover reads through the alias, slot rewrites in both
directions, the partial's unbound states staying its own, announcements
and listeners, and steering.
marcoroth added a commit to marcoroth/reactionview that referenced this pull request Sep 14, 2026
This pull request keeps a page render from being steered by a
`Herb-State` header, now that Herb compiles its state prelude into page
renders as well. It also moves the `herb` lock to the main that does so,
which is what makes the fix necessary and what turns `main` green again.

marcoroth/herb#2651 lets a render call bind or seed a partial's states
through a `Bindings` frame, and for that frame to reach a page render
the `fetch`-based state prelude had to compile into every template that
declares states, not only into the values program. The prelude reads
`__herb_state_overrides`, which this gem answers from the request
header. Left alone, a page render would take the header too, and a page
steered that way would be served without the `no-store` the values path
sets.

`StateOverridesHelper#__herb_state_overrides` now answers the header
only when the request asks for the slots format. A page render sees
nothing from the header while a render frame still reaches it, and the
tests cover both sides along with the layering in a values render, a
binding over the client's value and a seed under it.

The library change stands on its own, so it works against either Herb.
The tests that build a frame need the newer one, and they skip
themselves on a Herb without `Herb::Engine::Slots::Bindings`, so the
suite stays green across the versions the gemspec allows.

The lock bump belongs here instead of on its own, because on the newer
Herb `main` fails `the page render ignores overrides entirely`. The
prelude now reaches a page render, the helper still answers the header
on every request, and the page comes back steered. Bumping the lock
alone would leave `main` red, and this fix is what settles it. With
both, the suite is green at 219 runs with only the three pre-existing
skips, and the four frame tests run for real instead of skipping.

This branch was successfully deployed

1 active deployment
herb-tools (Preview) — c347fa4f Deployed Sep 14, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client-runtime Browser runtime for the Herb slots. engine Herb engine and Rails template compilation rbs RBS type signatures in sig/ 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