スロット述語が引き起こすコンテンツキャッシュ汚染の修正
render_in 実行前にスロット述語(header? など)を呼び出すと、content のキャッシュが汚染され、レンダリング時に渡したコンテンツブロックが無視されるバグが修正されました。
背景
render_in の呼び出し前にスロット述語を評価すると、コンテンツブロックがサイレントに無視されるという問題がありました。この問題は、コンポーネントのスロット構成をヘルパーで先に組み立て、後からコンテンツブロック付きでレンダリングするパターンで発生します。
問題の根本は __vc_get_slot の実装にありました。スロットの値を取得する際に、コンテンツ定義のスロットが確実に読み込まれるよう、content を先行評価します。
def __vc_get_slot(slot_name)
content unless defined?(@__vc_content_evaluated) && @__vc_content_evaluated
...
end
この先行評価によって @__vc_content にキャッシュが書き込まれます。render_in はリセット処理として @__vc_content_evaluated = false を実行しますが、キャッシュ本体である @__vc_content はクリアしていませんでした。その結果、render_in に渡されたコンテンツブロックは一切評価されず、古いキャッシュが返り続けます。
以下のような実用的なコードで容易に再現できます。column?(:options) の呼び出しが content の先行評価を引き起こし、その後の render に渡したブロックの内容が消える、という無音の失敗を招いていました。
def my_table
Ui::Table.new.tap do |table|
table.with_column "Name"
table.with_column :options unless table.column?(:options) # ここで content が評価される
end
end
<%= render my_table do %>
<%= render partial: "row" %> <%# ブロックの内容がサイレントに無視される %>
<% end %>
技術的な変更
修正は lib/view_component/base.rb の render_in メソッドへの1行追加に集約されます。@__vc_content_evaluated のリセットと合わせて、@__vc_content 自体も削除することでキャッシュを完全に無効化します。
変更前:
@__vc_content_evaluated = false
@__vc_render_in_block = block
変更後:
@__vc_content_evaluated = false
remove_instance_variable(:@__vc_content) if defined?(@__vc_content)
@__vc_render_in_block = block
remove_instance_variable を使っているのは、@__vc_content が未定義の場合に NameError が発生するためです。defined? で存在確認してから削除する形が採られています。
リグレッションテストも追加されました。SlotWithContentBlockComponent という専用のテスト用コンポーネントを新設し、スロット述語を render_in より前に呼び出してからコンテンツブロック付きでレンダリングし、ブロックの内容が正しく描画されることを検証します。
class SlotWithContentBlockComponent < ViewComponent::Base
renders_one :header
def call
out = +""
out << content_tag(:div, header, class: "header") if header?
out << content_tag(:div, content, class: "body") if content?
out.html_safe
end
end
def test_slot_predicate_before_render_does_not_poison_content_cache
component = SlotWithContentBlockComponent.new
component.with_header { "My Header" }
# render_in 前にスロット述語を呼び出してキャッシュを汚染する
assert component.header?
render_inline(component) do
"Body content from block"
end
assert_selector(".header", text: "My Header")
assert_selector(".body", text: "Body content from block")
end
設計判断
キャッシュの「フラグのみリセット」から「値も含めた完全削除」へという方針が採られました。
これまでの設計では、@__vc_content_evaluated(評価済みフラグ)と @__vc_content(キャッシュ値)の2つのインスタンス変数でキャッシュ状態を管理していました。render_in はフラグだけをリセットしていたため、キャッシュ値と状態フラグの間に不整合が生じる余地がありました。今回の修正はその不整合を根本から排除する形です。
nil を代入するのではなく remove_instance_variable で変数ごと削除している点も注目に値します。@__vc_content に nil が入った状態と変数が存在しない状態を区別しているコードが他に存在する場合、nil 代入では代替できないため、変数の完全な削除が選ばれています。
まとめ
本PRは1行の追加で長年潜在していたサイレント障害を修正しています。コンテンツキャッシュの「フラグ」と「値」を常にセットで管理するという原則が徹底されたことで、スロット述語の呼び出しタイミングに依存しない堅牢なキャッシュ無効化が実現されました。