ネストされたラムダスロットのi18nスコープ解決バグを修正
ViewComponentのラムダバックドスロットで相対翻訳キー t(".key") を使用した際に、ブロックが定義されたパーシャルではなく中間コンポーネントのスコープに解決されていたバグが修正されました。
背景
#2513 で報告されたこのバグは、3階層以上のネストを持つスロット構造において t(".key") の翻訳スコープが意図しないコンポーネントに解決されるというものでした。たとえば、パーシャル内で以下のような3階層のスロット呼び出しを行うと、shared.lambda_slot_translation.lambda_action ではなく lambda_slot_inner_component.lambda_action のような中間コンポーネントのスコープが使われてしまいます。
<%= render LambdaSlotOuterComponent.new do |outer| %>
<% outer.with_inner do |inner| %>
<% inner.with_aside do %>
<%= t(".lambda_action") %>
<% end %>
<% end %>
<% end %>
#2520 でこの問題を修正するために __vc_content_block_virtual_path が導入され、ブロック定義時点の @virtual_path をキャプチャしてブロック評価時に復元する仕組みが実装されました。しかし、ラムダバックドスロットでは修正が不完全でした。
技術的な変更
__vc_set_slot 内のラムダブランチが誤った仮想パスを参照していたことが根本原因です。#2520 ではラムダブランチにも with_captured_virtual_path によるラッピングが追加されましたが、渡していた値が slot.__vc_content_block_virtual_path(ブロック定義時のパーシャルのパス)ではなく @old_virtual_path(中間の親コンポーネントのパス)でした。
修正は lib/view_component/slotable.rb の __vc_set_slot メソッドに集中しています。ブロックがある場合にキャプチャした仮想パスをローカル変数 captured_block_virtual_path に保持し、それをラムダブランチに渡すように変更しています。
変更前:
def __vc_set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block)
slot_definition ||= self.class.registered_slots[slot_name]
slot = Slot.new(self)
# ...
if block
slot.__vc_content_block = block
slot.__vc_content_block_virtual_path = view_context.instance_variable_get(:@virtual_path)
end
# ...
renderable_value =
if block
renderable_function.call(*args, **kwargs) do |*rargs|
with_captured_virtual_path(@old_virtual_path) do # ← 誤り:中間コンポーネントのパスを使用
view_context.capture(*rargs, &block)
end
end
end
end
変更後:
def __vc_set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block)
slot_definition ||= self.class.registered_slots[slot_name]
slot = Slot.new(self)
captured_block_virtual_path = nil # ← ローカル変数を追加
# ...
if block
slot.__vc_content_block = block
captured_block_virtual_path = view_context.instance_variable_get(:@virtual_path) # ← ローカル変数にキャプチャ
slot.__vc_content_block_virtual_path = captured_block_virtual_path
end
# ...
renderable_value =
if block
renderable_function.call(*args, **kwargs) do |*rargs|
with_captured_virtual_path(captured_block_virtual_path) do # ← 正しいパスを使用
view_context.capture(*rargs, &block)
end
end
end
end
なお、slot.__vc_content_block_virtual_path は attr_writer で定義されており書き込み専用です。PR内では attr_reader を追加してスロットオブジェクト経由で読み出す案も検討されましたが、ローカル変数を介することで既存のインターフェースを変えずに問題を解決しています。
設計判断
attr_reader を公開せずローカル変数で解決する方式 が採用されました。slot.__vc_content_block_virtual_path に対して attr_reader を追加すれば直接読み出せますが、それはスロットの内部状態を外部に露出することになります。ローカル変数 captured_block_virtual_path を __vc_set_slot スコープ内に留めることで、既存インターフェースへの影響を最小限にしています。
非ラムダスロットは Slot#to_s 呼び出し時に評価を遅延するため attr_writer 経由で保持したパスを正しく使えていました。一方、ラムダバックドスロットは __vc_set_slot 内で即座に評価されるため、with_captured_virtual_path に渡す値の正確さが直接バグの有無を左右します。今回の変更はこの評価タイミングの違いに明確に対応したものです。
テストとして LambdaSlotOuterComponent → LambdaSlotInnerComponent → ラムダバックドスロットの3階層構造を持つフィクスチャが追加され、deeply_nested_translation_test.rb に test_translation_in_nested_lambda_backed_slot が追加されています。
まとめ
本PRは2行の変更で、ラムダバックドスロットにおける t(".key") の翻訳スコープ解決を正しいパーシャルのスコープに修正しています。非ラムダスロットと評価タイミングが異なるラムダスロットへの対応漏れが原因であり、ローカル変数を介したパスの受け渡しで既存インターフェースを保ちつつ修正を完結させた設計は、変更の局所性という観点で参考になります。