DelegateClass が暗黙ブロックを正しく転送するよう修正
Rails 7.0 の ActiveSupport::Delegation.DelegateClass が、ブロックを暗黙的に受け取るメソッドへのブロック転送を行わず、結果として呼び出し側に空の結果を返す問題を解消しました。
背景
ActiveSupport::Delegation.DelegateClass はラップしたクラスの公開メソッドごとにデリゲータメソッドを生成し、各メソッドのシグネチャは Method#parameters から自動構築されます。しかし、yield のみでブロックを受け取る(暗黙ブロック)メソッドは parameters に :block エントリが存在しないため、生成されたシグネチャにブロックが含まれず、実行時にブロックが 黙って落とされ ます。
この挙動は Ruby 標準ライブラリの DelegateClass と異なり、String#scan や Array#map などブロックを必須とするメソッドが期待通りに動作しないという正確性の回帰を引き起こします。コードベース全体で DelegateClass を利用するデコレータ(例: ActiveRecord の型メタデータや楽観的ロック)にも影響が波及するため、見逃しやすいが重大なバグです。
技術的な変更
activesupport/lib/active_support/delegation.rb に forwards_implicit_block フラグを導入し、シグネチャが "..." 以外かつ parameters に :block が無い場合に匿名ブロック & を付加して転送します。このロジックは既存のシグネチャ生成フローに最小限の分岐を追加するだけで、...(可変長引数)シグネチャは既にブロックを転送するため変更しません。
変更前:
method_def <<
"def #{method_name}(#{signature})" <<
" __getobj__.#{method_name}(#{signature})"
変更後:
# Methods that yield to an implicit block do not list a `:block`
# parameter, so forward an anonymous block to them too. The `...`
# signature already forwards the block.
forwards_implicit_block = signature != "..." && parameters.none? { |type, _name| type == :block }
if forwards_implicit_block
signature = signature.empty? ? "&" : "#{signature}, &"
end
method_def <<
"def #{method_name}(#{signature})" <<
" __getobj__.#{method_name}(#{signature})"
さらに、activesupport/test/delegation_test.rb が新規追加され、暗黙ブロックを持つ Target#each_doubled 等のメソッドが正しくブロックを受け取ることを検証します。テストは 6 件追加され、修正前は 5 件が失敗またはエラーになる点をカバーしています。
設計判断
ブロック転送の欠如は シグネチャ生成時の情報不足 が原因であるため、シグネチャに匿名ブロック & を明示的に付加 する方針が採られました。これにより、DelegateClass が Ruby 標準と同等の振る舞いを示すようになり、既存コードへの影響は最小限です。signature == "..." のケースは既にブロックを転送する実装があるため、条件分岐で除外しています。
この変更は 後方互換性を維持 しつつ、ブロックを受け取るメソッド全般で正しい動作を保証します。デリゲータ生成ロジックへの侵入は数行に留められ、他の機能やパフォーマンスへの副作用は報告されていません。
まとめ
DelegateClass が暗黙的にブロックを受け取るメソッドでもブロックを正しく転送できるようになり、Rails のデコレータ層全体で標準的な Ruby 挙動と一致しました。最小限のロジック追加で既存のインターフェースを保ちつつ、正確性の回帰を防止する重要な修正です。