ActionableError のアクションハッシュがサブクラス間で共有されるバグを修正
ActionableErrorで定義したアクションがサブクラス間で漏れ出す問題を、コピーオンライト方式でハッシュを更新する実装に変更し、クラス継承時の状態汚染を防止しました。
背景
ActionableError.action が共有ハッシュを直接変更していた ため、サブクラスで定義したアクションが親クラスや兄弟クラスに漏れ、意図しないアクションが検出されていました。
_actions は class_attribute としてデフォルト {} を持ち、サブクラスはデフォルトオブジェクトをそのまま継承するため、_actions[name] = block のインプレース更新が全クラスに反映されていました。
この状態はテスト subclass actions are not leaked to the parent or sibling subclasses で確認でき、修正前は "A" と "B" が親クラスにも漏れて失敗していました。
技術的な変更
action メソッドを不変なハッシュのマージに置き換えた ことで、毎回新しいハッシュが生成され、サブクラスごとに独立した状態が保たれます。
@@
- _actions[name] = block
+ self._actions = _actions.merge(name => block)
self._actions = _actions.merge(name => block) は既存の継承ハッシュを基にマージを行うため、親クラスの定義はそのまま引き継がれつつ、変更は現在のクラスのみへ適用されます。
設計判断
コピーオンライト方式を採用することで、 class_attribute の他の再代入ロジック(例: StructuredEventSubscriber#debug_only 修正 #57676)と同様のパターンを維持し、状態共有による副作用を回避しました。
インプレースでハッシュを変更する (_actions[name] = block) アプローチはシンプルですが、クラス階層全体に影響を波及させる危険があるため、今回のようなライブラリ内部での拡張では不適切です。
この設計は不変性を尊重しつつ、サブクラス固有の設定を安全に追加できる点で、将来的な拡張や他の class_attribute 利用箇所にも適用しやすい利点があります。
まとめ
修正により ActionableError のサブクラスは自クラスで定義したアクションだけを保持し、親クラスや兄弟クラスへ漏れません。これで誤ったアクション検出や予期しないディスパッチが防止され、フレームワークの拡張性と安全性が向上しました。