Skip to content

Refactor: Rework slots so each file has one job, and report compile errors as diagnostics - #2420

Merged
marcoroth merged 66 commits into
mainfrom
client-refactor
Aug 27, 2026
Merged

Refactor: Rework slots so each file has one job, and report compile errors as diagnostics#2420
marcoroth merged 66 commits into
mainfrom
client-refactor

Conversation

@marcoroth

Copy link
Copy Markdown
Owner

This pull request reworks the slots client and the slots compiler so that each file has one job and names it, and changes what the compiler does about a template it cannot compile.

The client had grown into a handful of files that each did several things, with private #fields that made the seams hard to see. The compiler had the same problem in reverse, with a template's identity spread across the payload, the markers and the dependency map. Neither had a good answer for a template that will not compile, which raised on the first thing it disliked and told the developer only a sentence.

The client

Slots was carrying the region index, the collections, the journal and the scanner. Each of those is now its own file with a delegate, in the shape ElementObserver already used, so a caller says what it wants and the holder says what happened. The same split runs through the state layer, which now separates what it holds from what it asks the server for, and through Actions, which reads an element's actions once instead of on every event.

Everything is addressed by name now. SlotIndex became RegionIndex, HerbRuntime became Runtime, and the files sit in slots/, state/, actions/, markup/, grammar/, outbox/ and shared/ according to what they do.

The manifest

A template used to say what it is in three places. The manifest is now the only one, carrying the file, the version, the names, the parts and the states, and both deliveries hand a page the same shape so a client reads them the same way. A project can gather its manifests ahead of rendering through Herb::Engine::Slots::Manifest::Collector, and a response delivers them once however many times a partial renders.

Compile errors

The slot compiler raised Herb::Engine::CompilationError from 43 places. CompilationError was a bare StandardError, so a developer got a sentence and a backtrace pointing into the compiler. A template with three states gave no clue which one was wrong.

The compiler now tells the visitor, which records a diagnostic and keeps walking, so one template reports everything wrong with it at once and each finding carries the line and column it belongs to. The messages were rewritten to match the linter's voice, saying what is wrong and then what to do:

The state `rate` has a Float default. Ruby and JavaScript disagree on how to print a float, so
declare it as an Integer or a String instead.

A visitor is fatal by default and still raises once the walk is over, which is what the CLI and the tests expect. A visitor that is not fatal lets the template compile without markers or a payload, so the page renders on the server and the findings reach the browser through Herb::Engine::Report::Middleware.

A refused template keeps the region it opened and drops only the manifest. A region carrying no manifest is how a page says the compiler refused it, so the client leaves everything inside one alone instead of complaining about every control the author wrote. Before that, one compile error became sixteen findings in the browser, fifteen of them false.

@github-actions github-actions Bot added ruby Ruby source for the gem and its libraries typescript TypeScript source across the javascript/ packages highlighter @herb-tools/highlighter syntax highlighting and diagnostic rendering rbs RBS type signatures in sig/ engine Herb engine and Rails template compilation rubygem The herb RubyGem and its packaging dev-tools @herb-tools/dev-tools visual debugging for HTML+ERB templates core @herb-tools/core shared AST nodes, interfaces, and utilities analysis client-runtime Browser runtime for the Herb slots. labels Aug 27, 2026
dac34ec moved the clause grammar and the attribute vocabulary into
parsing.ts and attributes.ts and dropped the re-exports from
directives.ts, but eleven linter rules and five language-service modules
import those names from @herb-tools/client/directives. Put the
re-exports back so both packages typecheck against the client again.
A payload carries both a block's whole output and the slots inside it.
Writing the whole output first replaced the interior markers, so the
interior values then had nowhere to land.
One round-trip fixture checked the client against the compiler. The
table covers every construct parity_test.rb lists, in both slot modes,
with the schema and the dependency map, plus the marker grammar, so a
change to either side shows up as a fixture diff.
The seeds marker, the part marker, the statics keys and the seeds filter
expression were written inline in the visitor and the values compiler.
One home for the grammar, and one filter expression for both compilers.
apply() opened a transaction of its own, so every payload was recorded
and the token was read by nothing. Wrapping apply in transaction()
records it; on its own it does not. The retain option had one value.
Three WeakMaps held facts about a slot on the side, which made
addressing a slot a walk over every collection's items and kept the
state layer from asking a slot where it lives.
claimed(), currentValue(), the markup operation, Region.start/end, the
two-answer row template, the data-herb-statics container no compiler
emits, and the payload scope of a send.
Three marker handlers each decided on their own when to trust the parse
stacks and when to fall back to document geometry, and had drifted: a
slot rescanned inside an item never found the item. A scan now seeds its
stacks once, from the caller's context or one locate(), markers it has
already indexed replay onto the stacks, and the handlers read nothing
else. locate() and placements() are public, and the state layer uses
them instead of its own copies of the geometry.
Twenty-seven switches on anchor.kind expressed the same five operations:
the range, the host element, the element that takes attributes, the
current markup or text, and whether it is still on the page.
The compiler parks statics; the client called the same fragments
skeletons, a word wanted for loading placeholders. parked() and
parkedKeys() replace skeletonFor() and skeletonKeys().
A block like <% foo do %> received a slot and a marker pair, but the
values compiler never produced a value for it and the client never read
its type. Its closing marker was also the last expression of any lambda
or helper block that wrapped it, which returned the buffer instead of
the block's value.
A collection that rendered rows shipped a blanked copy of a row it
already contained. The client builds an added row from a live one, so
the copy was only needed when no row rendered.
_herb_covered_branches was a local reset on every render, so a partial
rendered fifty times parked the same branch fifty times. It is now a
view ivar keyed by the template identifier, initialized once per
response and marked as each branch's statics are emitted.
…collection

JavaScript sorts integer-like object keys, so a payload lost the order
the server rendered a collection in. An unkeyed collection emits no item
markers, so the items it reported could never be applied; its interior
is now captured and discarded rather than landing in the scope around
it. Region-scoped seeded states ship with the values, the way an item's
already do.
Under an escaping engine the marker's key went through the escape
function while the payload's went through to_s, so a key holding any of
&<>"' named a row no payload could reach. The marker now appends the
key to the buffer directly.
Nodes added into two different items of one collection share a parent,
so seeding once for the group indexed the second node into the first
node's item. A root re-seeds from its own placement unless the previous
root left a marker open.
A slot inside a collection nested in another collection was addressed
as if its collection sat at the region, so the address resolved to
nothing and an inverse was dropped. The same lookup now finds a nested
collection when its items are scanned.
An element inside a collection nested in another collection resolved
only against the innermost item, so a state the outer collection
declares was unreachable from the inner one.
Comment thread javascript/packages/client/test/markers.test.ts Fixed
@marcoroth marcoroth added the refactor Internal cleanup with no intended behavior change label Aug 27, 2026
@marcoroth marcoroth changed the title Herb: Rework slots so each file has one job, and report compile errors as diagnostics Refactor: Rework slots so each file has one job, and report compile errors as diagnostics Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 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 f697bf5


✅ Preview deployment has been cleaned up.

@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026

Copy link
Copy Markdown
@herb-tools/client

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

@herb-tools/core

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

@herb-tools/dev-tools

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

@herb-tools/formatter

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

@herb-tools/language-server

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

@herb-tools/linter

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

commit: f697bf5

@marcoroth
marcoroth merged commit 9de742f into main Aug 27, 2026
36 checks passed
@marcoroth
marcoroth deleted the client-refactor branch August 27, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

analysis client-runtime Browser runtime for the Herb slots. core @herb-tools/core shared AST nodes, interfaces, and utilities dev-tools @herb-tools/dev-tools visual debugging for HTML+ERB templates engine Herb engine and Rails template compilation highlighter @herb-tools/highlighter syntax highlighting and diagnostic rendering rbs RBS type signatures in sig/ refactor Internal cleanup with no intended behavior change ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging typescript TypeScript source across the javascript/ packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants