Railsフォームヘルパーとの組み合わせでyield内容が誤った位置に出力されるバグを修正
ViewComponentがRailsのフォームヘルパー(form.labelなど)とyieldブロックを組み合わせた際に、ブロックの内容がタグの外側に出力されてしまうバグが修正されました。根本原因は@output_bufferの同期ずれにあり、captureメソッドのオーバーライドによってビューコンテキストのバッファと一致させることで解決しています。
背景
#2617で報告されたこのバグは、ViewComponentからパーシャルをレンダリングし、そのパーシャル内でRailsのフォームビルダーを使ってyieldするケースで発生していました。具体的には form.label :something do ... yield ... end という構造で、期待される出力が <label>world</label> であるにもかかわらず、実際には world <label></label> となっていました。
問題の連鎖は form_with から始まります。form_with はフォームビルダーを生成する際に @template_object としてコンポーネント自身を参照します。その後、パーシャル内でフォームビルダーが form.label :something do ... end を呼び出すと、ビルダーは @template_object.capture(builder, &block) を実行します。つまり、コンポーネントの @output_buffer に対してキャプチャを行います。
しかしRailsの Template#render → _run は、パーシャルをレンダリングする際にビューコンテキストの @output_buffer を新しい OutputBuffer に差し替えます。パーシャルのテンプレートコードはこの新しいバッファに書き込む一方、コンポーネントの @output_buffer は render_in 時点の古いバッファを指したままです。結果として form.label のキャプチャは空の(古い)バッファを読み取り、ブロックの出力はラベルタグで包まれることなくパーシャルのメインストリームに漏れ出してしまいます。
技術的な変更
ViewComponent::Base に capture メソッドのオーバーライドを追加し、キャプチャ前に @output_buffer をビューコンテキストの現在のバッファと同期させることで問題を解決しています。
追加されたコード(lib/view_component/base.rb):
def capture(...)
old_output_buffer = @output_buffer
@output_buffer = view_context.output_buffer if view_context
super
ensure
@output_buffer = old_output_buffer
end
この実装は3段階の手順で動作します。まず現在の @output_buffer を退避し、次にビューコンテキストが存在する場合は view_context.output_buffer(パーシャルのために差し替えられた新しいバッファ)に切り替えます。super でRailsの標準 capture を呼び出した後、ensure ブロックで元のバッファを必ず復元します。view_context が nil の場合(コンポーネントが単体テストされる状況など)は同期をスキップするため、既存の動作を壊しません。
テストには PartialWithYieldFormComponent が追加されました。コンポーネントのテンプレートから form_with でフォームを生成し、パーシャル shared/_yielding_form_partial に渡します。パーシャルは form.label :something do ... yield ... end の構造を持ち、テストは render_inline の結果から label 要素のHTMLに "world" が含まれることを検証します。
def test_render_partial_with_yield_form
assert_includes render_inline(PartialWithYieldFormComponent.new).css("label").to_html, "world"
end
設計判断
バッファの一時的な差し替えとリストアという最小限の介入で問題を解決するアプローチが採用されました。
PRのコメントにあるとおり、@template_object がコンポーネントを指すという構造はRailsのフォームビルダーの内部実装に起因しており、ViewComponent側からその動作を変更することは困難です。そのため、フォームビルダーがコンポーネントの capture を呼び出すタイミングで、コンポーネント側がビューコンテキストの現在のバッファを参照するよう自ら合わせに行く設計が選ばれています。ensure による確実なリストアは、キャプチャ中に例外が発生した場合でも @output_buffer の状態が壊れないことを保証します。
この変更はコンポーネントの capture メソッドのみを対象としており、render_in やその他のバッファ管理ロジックには触れていません。影響範囲を最小限に抑えながら、バッファの同期ずれという根本原因を直接修正した判断といえます。
まとめ
本修正は、ViewComponentとRailsのパーシャルレンダリング機構が @output_buffer を独立して管理することで生じた、見えにくいバッファ同期の問題を解消します。capture の呼び出しスコープでビューコンテキストのバッファに追従するという局所的な修正によって、既存のコードに影響を与えることなくフォームヘルパーとyieldの組み合わせが正しく動作するようになりました。