Summary
erb-no-unused-expressions already knows that ViewComponent slot setters are intentional side-effecting calls: when the receiver is the object yielded by a render(...) do |component| block, a block-less <% component.with_foo(...) %> is correctly not flagged.
The exemption is too narrow. It's scoped to the receiver yielded by the render block, so it doesn't apply to slot setters called on a builder object yielded by a nested slot. ViewComponent slots can themselves yield a builder (via renders_one/renders_many with a block), and you populate that builder's slots the same way — <% item.with_icon(...) %>. Those nested setters get flagged as unused expressions even though they're identical, valid usage.
Rule: erb-no-unused-expressions
Minimal reproduction
Components (sketch):
class ListComponent < ViewComponent::Base
renders_many :items, ItemComponent # `with_item` yields an ItemComponent
end
class ItemComponent < ViewComponent::Base
renders_one :icon, IconComponent # `with_icon` sets a slot, no block needed
end
Template:
<%= render ListComponent.new do |list| %>
<% list.with_item do |item| %>
<% item.with_icon("home") %> <%# ← flagged, but this is correct usage %>
<% end %>
<% end %>
[error] Avoid unused expressions in silent ERB tags. `item.with_icon("home")` is
evaluated but its return value is discarded. Use `<%= ... %>` to output the value or
remove the expression. (erb-no-unused-expressions)
Note that list.with_item (the outer slot setter) is not flagged — only the nested item.with_icon.
Isolating the trigger
The exemption keys purely on the receiver being a render block's yielded parameter — not on the with_* name, not on block presence, and not on trailing text. The following cases make the boundary explicit:
<% bare_call("z") %> <%# flagged — outside any render block %>
<%= render Outer.new do |outer| %>
<% outer.slot_setter("a") %> <%# NOT flagged — receiver is render block param %>
<% other_receiver.slot_setter("a2") %> <%# flagged — receiver is NOT the render block param %>
<%= render Inner.new do |inner| %>
<% inner.slot_setter("b") %> <%# NOT flagged — receiver is (nested) render block param %>
<% end %>
<% outer.with_item do |item| %>
<% item.slot_setter("c") %> <%# flagged — receiver is a slot-block builder, not a render param %>
<% end %>
<% end %>
<% plain_method do |x| %>
<% x.slot_setter("d") %> <%# flagged — receiver is an arbitrary block param %>
<% end %>
| Receiver origin |
Flagged? |
render(...) do |x| block param (outer, inner) |
No |
Any other receiver inside a render block |
Yes |
Builder yielded by a nested slot block (item from with_item do |item|) |
Yes (false positive) |
Block param of a non-render method block |
Yes |
| No enclosing block |
Yes |
The same slot_setter method is exempt on outer.slot_setter and flagged on item.slot_setter — confirming the check is structural/receiver-based, and that the nested-slot builder simply isn't recognized.
Expected behavior
Slot setters invoked on a builder object yielded by a nested slot block should be treated the same as slot setters on the top-level render block param: not flagged. <% item.with_icon("home") %> is the correct, documented way to populate a slot — the return value is meant to be discarded, which is exactly why it belongs in a silent <% %> tag. The rule's suggested fix (switch to <%= %>) would be wrong here: it would output the setter's return value and produce duplicated/malformed markup.
Suggested fix
Extend the existing render-block exemption so it also recognizes the builder objects yielded by slot blocks. Concretely, when a <% receiver.method(...) %> call's receiver is the block parameter of an enclosing block that is itself a slot setter (with_*) — at any nesting depth — apply the same exemption already used for render(...) do |x| params.
Environment
herb gem v0.10.1, libprism v1.9.0, libherb v0.10.1
@herb-tools/linter v0.10.1
- Node.js v24.13.0
- Reproduced via
herb lint
Summary
erb-no-unused-expressionsalready knows that ViewComponent slot setters are intentional side-effecting calls: when the receiver is the object yielded by arender(...) do |component|block, a block-less<% component.with_foo(...) %>is correctly not flagged.The exemption is too narrow. It's scoped to the receiver yielded by the
renderblock, so it doesn't apply to slot setters called on a builder object yielded by a nested slot. ViewComponent slots can themselves yield a builder (viarenders_one/renders_manywith a block), and you populate that builder's slots the same way —<% item.with_icon(...) %>. Those nested setters get flagged as unused expressions even though they're identical, valid usage.Rule:
erb-no-unused-expressionsMinimal reproduction
Components (sketch):
Template:
Note that
list.with_item(the outer slot setter) is not flagged — only the nesteditem.with_icon.Isolating the trigger
The exemption keys purely on the receiver being a
renderblock's yielded parameter — not on thewith_*name, not on block presence, and not on trailing text. The following cases make the boundary explicit:render(...) do |x|block param (outer,inner)renderblockitemfromwith_item do |item|)rendermethod blockThe same
slot_settermethod is exempt onouter.slot_setterand flagged onitem.slot_setter— confirming the check is structural/receiver-based, and that the nested-slot builder simply isn't recognized.Expected behavior
Slot setters invoked on a builder object yielded by a nested slot block should be treated the same as slot setters on the top-level
renderblock param: not flagged.<% item.with_icon("home") %>is the correct, documented way to populate a slot — the return value is meant to be discarded, which is exactly why it belongs in a silent<% %>tag. The rule's suggested fix (switch to<%= %>) would be wrong here: it would output the setter's return value and produce duplicated/malformed markup.Suggested fix
Extend the existing render-block exemption so it also recognizes the builder objects yielded by slot blocks. Concretely, when a
<% receiver.method(...) %>call's receiver is the block parameter of an enclosing block that is itself a slot setter (with_*) — at any nesting depth — apply the same exemption already used forrender(...) do |x|params.Environment
herbgem v0.10.1, libprism v1.9.0, libherb v0.10.1@herb-tools/linterv0.10.1herb lint