Skip to content

Linter Rule: Disallow render calls that would raise an ActionView::StrictLocalsError

Rule: actionview-no-strict-locals-error

Description

Detects render calls whose locals do not match the target partial's <%# locals: (...) %> declaration, in either direction: a required local that is not passed, or a local the partial does not declare.

Rationale

A strict locals declaration is a contract, and Rails enforces it at render time. A partial declaring <%# locals: (user:) %> raises an ActionView::StrictLocalsError both when it is rendered without user: and when it is rendered with a local it never declared. Because the failure only happens when that branch of the template actually runs, a broken render call can sit in a rarely visited view for a long time before anyone sees it.

Both directions are the same runtime error, which is why they are one rule rather than two.

This is the one rule in the linter that reads a second file. It resolves the partial name the same way Action View does, reads the declaration out of the resolved partial, and compares it against the locals the render call passes.

Locals with a default value are never reported as missing, since Rails supplies the default when the caller leaves them out. A ** keyword rest in the declaration only affects the undeclared direction: it lets a caller pass extra locals, but the declared required ones stay required.

Requirements

The rule needs an index of the project's partials, which the CLI builds from the project root. It stays silent when it has no index, so it does not report on a single source string, in the playground, or anywhere the surrounding project is unknown.

It also only reports what it can prove. These are all skipped:

SkippedWhy
render partial_nameThe name is only known at runtime
render "users/#{kind}"Same, the name is interpolated
render "users/card", **localsThe splat can carry any local
render partial: "users/card", locals: some_hashThe hash is not a literal
render partial: "users/card", collection: @usersCollection renders pass the local implicitly
render partial: "users/card", object: @userObject renders pass the local implicitly
A partial name that does not resolve to a fileNothing to compare against
A partial with no strict locals declarationThere is no contract to check

Examples

Given app/views/users/_card.html.erb:

erb
<%# locals: (user:, size: "large") %>

<div class="card">
  <%= user.name %> (<%= size %>)
</div>

✅ Good

erb
<%= render "users/card", user: @user %>
erb
<%= render partial: "users/card", locals: { user: @user, size: "small" } %>

🚫 Bad

erb
<%= render "users/card" %>
erb
<%= render partial: "users/card", locals: { size: "small" } %>
erb
<%= render "users/card", user: @user, color: "red" %>

References

Released under the MIT License.