Repository navigation
Engine: Bind a partial's states to its caller through state: on render - #2651
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-bindings
branch
from
September 14, 2026 16:06
9cfc614 to
c347fa4
Compare
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
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 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.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:statedefault, 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:Bindingskeeps a fiber-local stack of frames, andStateOverrides.resolvelayers 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 theHerb-Stateheader 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 likeopen: falseseeded totruewould rendertrueon the server while the client fell back tofalse. 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
**splator a non-symbol key, sincestate: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 anystate: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_cardand notalbum_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 fromcontext.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, sostate: { 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
bindingstable keyed by the child slot index, next toparts, 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'sdata-herb-slot, so the client can find the enclosing slot.