Skip to content

erb-no-unused-expressions: false positive on ViewComponent slot setters called on a nested slot's yielded builder #1822

Description

@andrewmcodes

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions