Repository navigation
Client: Alias a bound partial's states to its caller's - #2652
Merged
Merged
Conversation
marcoroth
added this pull request to stack #2653
September 14, 2026 08:56
🌿 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. |
@herb-tools/analysis
@herb-tools/browser
@herb-tools/client
@herb-tools/config
@herb-tools/core
@herb-tools/dev-tools
@herb-tools/formatter
herb-language-server
@herb-tools/highlighter
@herb-tools/language-server
@herb-tools/language-service
@herb-tools/linter
@herb-tools/minifier
@herb-tools/node
@herb-tools/node-wasm
@herb-tools/printer
@herb-tools/rewriter
stimulus-lint
@herb-tools/tailwind-class-sorter
commit: |
This was referenced Sep 14, 2026
marcoroth
force-pushed
the
render-state-aliases
branch
from
September 14, 2026 16:06
2abe99a to
c79f28c
Compare
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
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 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
placementscould always re-derive it per node, but nothing needed it persistently until a partial's state had to know whose it is. A manifest'sbindingstable, keyed by the child slot index next toparts, names the partial and maps each of its bound states to the caller's.resolveStatefollows 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.valueAtresolves first, which makes every read, steering value, derived state, branch settle and listener alias-aware in one place.setStateresolves 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 adata-herb-set="open=false"inside the partial writesmodal_openon 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#steeringreads throughvalueAt, so a bound partial'sHerb-Stateentry carries the caller's current value under the partial's own file, and the engine'sBindingsframe wins over it on the way back.An element inside a bound partial now resolves innermost first, so the partial's own
openwins 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.