Skip to content

Linter Rule: Validate how declared states are read

Rule: herb-state-valid-reads

Description

Validates every read of a declared state. A state is read bare (<%= attempts %>, <% if pending %>, <% unless pending %>), as a predicate on a boolean (pending?), compared to a literal of its own type (sort == "name", sort != "date"), ordered against an Integer literal when it is an Integer (attempts > 3), or compared with another state of the same kind (counter1 > counter2), or switched over with literal when arms. Those conditions also combine with && and || (pending? || failed?), as long as every side reads a state. A boolean attribute accepts the same read shapes, since its presence is a two-arm conditional (disabled="<%= draft == "" %>"). Anything else, a computed expression, a predicate on a non-boolean, a comparison against a non-literal or a mismatched literal, or a combination that mixes a state with server Ruby, is flagged.

Rationale

The client resolves state reads itself, without the server. That works because every allowed shape is a lookup or a comparison both languages compute identically, and a &&/|| combination of those shapes is resolved one condition at a time. A computed read (attempts + 1, attempts * 2 > 3) would need a Ruby evaluator in JavaScript, so the engine rejects it at compile time. A combination like pending? && current_user.admin? has the same problem on its server side, since the client holds no value for it. An unless reads like an if with its arms inverted, so every if shape works there too.

The engine raises all of these as compile errors when the template renders. This rule reports the same findings in the editor first.

Examples

✅ Good

erb
<%# herb:slots client %>
<%# herb:state (pending: false, attempts: 0, sort: "name") %>

<p><%= attempts %></p>

<% if pending? %>Sending<% else %>Sent<% end %>

<% if sort == "name" %>By name<% elsif sort == "date" %>By date<% end %>

<% if pending? || attempts > 3 %>Hold on<% else %>Ready<% end %>

<% case sort %>
<% when "name" %>By name
<% when "date" %>By date
<% end %>
erb
<%# herb:slots client %>
<%# herb:state (draft: "") %>

<input value="<%= draft %>" autocomplete="off">
<button disabled="<%= draft == "" %>">Send</button>

🚫 Bad

erb
<%# herb:slots client %>
<%# herb:state (pending: false, attempts: 0, sort: "name") %>

<p><%= attempts + 1 %></p>
`attempts + 1` computes with the state `attempts`, and the client cannot run Ruby to keep the result current. Show the value with `<%= attempts %>`, or declare a second state for the computed answer and set it from app code. (herb-state-valid-reads)
<% if pending? && current_user.admin? %>Retry as admin<% end %>
`pending? && current_user.admin?` combines a state with `current_user.admin?`, which the client cannot evaluate. Split the server condition into its own conditional, or compute it into a second state set from app code. (herb-state-valid-reads)
<% if attempts? %>Tried<% end %>
`attempts?` reads the Integer state `attempts` as a predicate. Write `attempts` bare, or declare a boolean flag. Only a boolean state reads with a `?`. (herb-state-valid-reads)
<% if sort == params[:sort] %>Current<% end %>
`sort == params[:sort]` compares the state `sort` against something that is not a literal or another declared state. Compare against a literal, like `sort == "name"`, since the client resolves a comparison by lookup. (herb-state-valid-reads)
<% if sort == 3 %>Odd<% end %>
`sort == 3` compares the String state `sort` against an Integer literal, so it can never match. Compare against a String, or redeclare the state. (herb-state-valid-reads)

Limits

The rule matches state names by token, so an expression that merely contains a declared name is flagged as computing with it. With a state named sort, both t("sort.by") and f.text_field :sort draw the offense. The engine rejects the same expressions at compile time, so the linter mirrors it. Short generic state names collide easily; a more specific name avoids the whole class.

A conditional whose first arm reads no state compiles as a server conditional, and a state read in a later arm is silently inert at runtime. The rule stays quiet on that shape today, matching the engine. Put the state arm first when the client should drive the branch.

References

-

Released under the MIT License.