Skip to content

Client: Alias a bound partial's states to its caller's - #2652

Merged
marcoroth merged 1 commit into
render-state-bindingsfrom
render-state-aliases
Sep 14, 2026
Merged

marcoroth merged 1 commit into
render-state-bindingsfrom
render-state-aliases

Conversation

@marcoroth

@marcoroth marcoroth commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

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
marcoroth added this pull request to stack #2653 September 14, 2026 08:56
@github-actions github-actions Bot added typescript TypeScript source across the javascript/ packages 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 c79f28c


✅ 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@2652

@herb-tools/browser

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

@herb-tools/client

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

@herb-tools/config

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

@herb-tools/core

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

@herb-tools/dev-tools

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

@herb-tools/formatter

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

herb-language-server

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

@herb-tools/highlighter

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

@herb-tools/language-server

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

@herb-tools/language-service

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

@herb-tools/linter

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

@herb-tools/minifier

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

@herb-tools/node

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

@herb-tools/node-wasm

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

@herb-tools/printer

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

@herb-tools/rewriter

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

stimulus-lint

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

@herb-tools/tailwind-class-sorter

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

commit: c79f28c

@marcoroth
marcoroth merged commit 9040568 into main Sep 14, 2026
25 checks passed
@marcoroth
marcoroth deleted the render-state-aliases branch September 14, 2026 16:54
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
…der (#2651)

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.

```erb
<%# 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:

```ruby
::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 added a commit that referenced this pull request Sep 23, 2026
#2698)

This pull request updates `herb-state-valid-reads` to stop reporting two
patterns the engine compiles today.

The first is a server-derived read inside a `<Fragment>`. The component
exists for exactly this shape, and its `<Fallback>` is the markup the
client shows the instant a state write invalidates a read inside it.

The second is a `state:` entry on a render call, shipped in #2649
through #2652.

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

A bare name there binds one of the partial's states to a state of the
calling template, and anything else seeds the partial's state with a
value the server computes once. Neither is a read the client resolves,
so neither belongs to this rule. The call's ordinary locals are still
checked, so `tries: attempts + 1` draws the offense as before.

This branch was successfully deployed

1 active deployment
herb-tools (Preview) — c79f28c3 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. typescript TypeScript source across the javascript/ packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant