`render_in` の `:object` オプション衝突問題を明示的に解消
ActiveModel::Conversion#render_in において、呼び出し元が :object を渡した際に発生する ArgumentError またはサイレントな上書きを防ぐため、options.delete(:object) による明示的な除去が追加されました。
背景
#57349 で ActiveModel::Conversion に #render_in のデフォルト実装が追加されました。この実装は partial: to_partial_path と object: self を組み合わせてビューパーシャルをレンダリングするもので、render person や render renderable: person, locals: { shout: true } といった呼び出しに対して後方互換性を維持しながら Active Model レベルの柔軟なレンダリング制御を提供します。
しかし、この実装には潜在的な問題がありました。render_in に :object キーを含む options が渡された場合(例: render(renderable: person, locals: { object: other }))、メソッド内部でキーワード引数のスプラット展開が行われる際に ArgumentError: duplicated keyword argument が発生する可能性がありました。さらに、Action View が内部で **options を汎用的に受け取る場合には、呼び出し元の :object が self を静かに上書きするという逆の問題も起きえました。
本 PR はこの Follow-up として、:object の優先ポリシーを明確にするために作成されました。
技術的な変更
activemodel/lib/active_model/conversion.rb の render_in メソッドに1行が追加されました。view_context.render を呼び出す前に options.delete(:object) を実行することで、呼び出し元が渡した :object を明示的に除去します。
変更前:
def render_in(view_context, **options, &block)
view_context.render(partial: to_partial_path, object: self, **options, &block)
end
変更後:
def render_in(view_context, **options, &block)
options.delete(:object)
view_context.render(partial: to_partial_path, object: self, **options, &block)
end
:object を除去した後に object: self を渡すことで、「self が常に勝つ」という優先ポリシーが一貫して保証されます。:locals、:partial、:layout などその他のオプションはそのまま転送されます。
テストも追加されており、contact.render_in(view_context, object: other, locals: { foo: "bar" }) を呼び出したとき、例外が発生しないこと、options[:object] が other ではなく contact になること、:locals の内容は維持されることの3点が検証されています。
test "#render_in caller-supplied :object is silently dropped, not an error" do
contact = Contact.new
other = Contact.new
view_context = Object.new
def view_context.render(**options)
options
end
options = nil
assert_nothing_raised do
options = contact.render_in(view_context, object: other, locals: { foo: "bar" })
end
assert_equal contact, options[:object]
assert_equal "bar", options.dig(:locals, :foo)
assert_equal contact.to_partial_path, options[:partial]
end
設計判断
呼び出し元の :object をサイレントに破棄する設計が採用されました。
PR の説明では「self が常に :object として使われる」という明確な優先ポリシーが示されています。ArgumentError を発生させてエラーを明示する方法も考えられますが、本実装では呼び出し元が意図せず :object を渡した場合(例えば locals: { object: other } のように渡された場合)にも静かに処理を続けることを選択しています。これは render_in のセマンティクスが「このモデルインスタンス自身をレンダリングする」ことに固定されているためであり、外部からの :object 注入を許容する設計余地を持たせないという判断です。
変更は1行の追加のみに留まっており、:object 以外のオプションへの影響はありません。
まとめ
本 PR は ActiveModel::Conversion#render_in における「self が常に rendered object になる」という設計意図を、options.delete(:object) という1行で明示的にコードへ落とし込んだ変更です。ポリシーの暗黙的な期待をコードレベルで保証することで、呼び出し元の予期しない入力に対するロバスト性が高まっています。