Skip to content

Client: Add state to set a page's state and write what it can - #2282

Merged
marcoroth merged 1 commit into
slots/propagationfrom
slots/state
Aug 18, 2026
Merged

Client: Add state to set a page's state and write what it can#2282
marcoroth merged 1 commit into
slots/propagationfrom
slots/state

Conversation

@marcoroth

@marcoroth marcoroth commented Aug 18, 2026

Copy link
Copy Markdown
Owner

This pull request adds state to the runtime, so a page can say what changed and let the client write the parts it can answer for itself.

apply says what to do with values once a page has them. Getting them was left to the page, which is to collect the state, put it in the query string, fetch, apply, then put the address bar back. That loop is the other half of the protocol, so it belongs next to apply.

const { state } = HerbRuntime.start()

await state.set({ query: "ruby", page: 1 })
state.set("query", "")
state.get("query")

The query string is the state, so the address bar keeps matching the page and back, forward and bookmarking keep working with nothing held on the server. set takes an object because one interaction usually changes several things at once, and everything set together travels as one request. It resolves to the report, because a page that shows what an update did needs it.

Writing before the server answers

Given the map the compiler builds, a page can write some slots itself. A slot whose expression is the state is written by copying, escaped the way the template would have escaped it, so a search box updates as fast as it is typed in while the count above it waits for the answer.

const report = await state.set("query", "ruby")
// { applied, deferred, written, restored, stale, failed }

written counts the optimistic writes and applied counts what the reply changed on top of them, so a reply that agrees reports 0. Confirming a write costs nothing, because a value equal to the one on the page is not written, and only a correction touches the DOM.

Escaping is not cosmetic here. An unescaped write would put live markup on the page for the length of a round trip and then be corrected, so matching what the server will send is what makes the confirmation free.

Three ways it goes wrong

A request that fails puts back every value it wrote, and the state it wrote them for, so a page does not keep claiming something the server never accepted.

A reply that arrives after a newer write has gone out is dropped, since applying it would undo something newer than itself. Sequencing is per key, so an unrelated key in flight does not discard an answer.

A slot the client cannot compute is left alone and comes back in the reply like any other.

Transport

The transport is a function, so the package assumes nothing about the protocol and its tests need no network. The default asks the same URL for the slots format, which is what ReActionView serves.

HerbRuntime.start({
  state: {
    transport: async (request, signal) => fetch(build(request), { signal }).then(response => response.json()),
    debounce: 150,
    history: false,
  },
})

Writing the value attribute does not move the property the browser reads, so an input keeps showing what was typed until the property follows. Every demo copied it back by hand. Doing it where the write is announced covers the server's writes too.

@github-actions github-actions Bot added typescript TypeScript source across the javascript/ packages client-runtime Browser runtime for the Herb slots. labels Aug 18, 2026
@nx-cloud

nx-cloud Bot commented Aug 18, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit a5e3fa8

Command Status Duration Result
nx run-many -t test --all --parallel --exclude=... ✅ Succeeded 3m 50s View ↗
nx run-many -t build --all --exclude=herb-langu... ✅ Succeeded 46s 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:30:04 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 26c8927


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

commit: 26c8927

@marcoroth marcoroth added the reactivity Reactive ERB templates: diff and re-render only what changed label Aug 18, 2026
@marcoroth
marcoroth merged commit 2492ea1 into main Aug 18, 2026
42 checks passed
@marcoroth
marcoroth deleted the slots/state 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.
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. reactivity Reactive ERB templates: diff and re-render only what changed typescript TypeScript source across the javascript/ packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant