FormBuilder#to_partial_path が Builder 以外のサブクラスで nil を返す不具合を修正
ActionView::Helpers::FormBuilder._to_partial_path が、クラス名に Builder サフィックスが無いサブクラスで nil を返していたバグを、文字列置換メソッドの変更により解消しました。これにより、カスタムビルダーの名前付け規則に依存せず正しいパーシャルパスが生成されます。
背景
FormBuilder#to_partial_path はビルダーオブジェクトをテンプレートに渡す際に使用される内部メソッドで、クラス名からパーシャル名を導出します。元の実装は String#sub! を用いて "_builder" サフィックスを除去していましたが、パターンがマッチしない場合は nil が返り、||= によるキャッシュが nil になるため以降の呼び出しでも常に nil が返っていました。この挙動は Ruby の仕様に起因する典型的なミスリーディングで、AdminForm のように Builder で終わらないサブクラスでパーシャルが見つからずレンダリングが失敗していました。
テストスイートにも LabelledFormBuilderSubclass というサブクラスが含まれ、同様に nil が返っていることが確認されていました。従来は *Builder という命名規則が事実上の前提となっていたため問題が表面化しにくく、実装上のバグとして長年残存していました。今回の PR はこの隠れた不具合を顕在化し、正しいパーシャルパスを返すように修正しています。
技術的な変更
FormBuilder のクラスメソッド _to_partial_path で使用していた String#sub! を String#sub に置き換えました。sub! は置換が行われなかった場合に nil を返すのに対し、sub は元文字列をそのまま返すため、キャッシュ変数 @_to_partial_path が常に文字列を保持します。変更前後の差分は次のとおりです:
@@ -1706,7 +1706,7 @@ def multipart=(multipart)
end
def self._to_partial_path
- @_to_partial_path ||= name.demodulize.underscore.sub!(/_builder$/, "")
+ @_to_partial_path ||= name.demodulize.underscore.sub(/_builder$/, "")
end
def to_partial_path
この修正により、以下のように期待通りのパーシャルパスが取得できます。ActionView::Helpers::FormBuilder._to_partial_path は "form"、LabelledFormBuilder._to_partial_path は "labelled_form" のままです。LabelledFormBuilderSubclass._to_partial_path は "labelled_form_builder_subclass"、AdminForm._to_partial_path は "admin_form" と正しく変換されます。従来のケースは影響を受けず、サフィックスが存在すれば除去されます。
設計判断
今回の変更は 最小限の差分 で問題を解決することを優先しています。sub! から sub への置換は 1 行の変更で済み、既存のキャッシュロジックや API 署名を変更せずに済むため、後方互換性が保たれます。また、@_to_partial_path が常に文字列になることで nil がキャッシュされるリスクが排除され、余計な再計算も防げます。
キャッシュは ||= 演算子で遅延評価されますが、sub が常に文字列を返すため一度計算された文字列が永続的に保存されます。これにより、ビルダークラスが多数存在する大規模アプリでも余計な文字列生成が抑制され、パフォーマンスに悪影響を与えることはありません。設計上のトレードオフはなく、コード可読性と安全性が向上した点が主な利点です。
まとめ
FormBuilder#to_partial_path が Builder で終わらないサブクラスでも正しいパーシャル名を返すようになり、カスタムビルダーの命名に制約がなくなりました。シンプルな文字列置換メソッドの差し替えだけで、長年潜在していたバグとキャッシュの不整合が解消された点が本変更の核心です。