ViewReloaderのウォッチャー生成をビューパス登録後まで遅延させる
ActionView::CacheExpiry::ViewReloader において、ビューパスが登録されていない状態でウォッチャーを構築しないよう修正されました。これにより、Spring のようなプリローダーを使った環境で、不要なファイルシステムスキャンが大幅に削減されます。
背景
#51308(「最初の updated? チェックまでビューウォッチャーの構築を遅延する」)の修正には、エッジケースが残っていました。@watcher が nil のまま維持されることを前提とした遅延ガードが、dirs_to_watch が空配列の状態で updated? が呼ばれた場合に破綻していました。
その具体的なシナリオは Spring のウォームキャッシュ時に発生します。Spring はフォーク前に Rails.application.reloaders を直接走査して reload! が必要かどうかを判断します。この時点では Reloader.check! をバイパスするため、多くのエンジンの prepend_view_path 呼び出し(ActiveSupport.on_load(:action_mailer) / :action_controller_base のコールバック内に存在する)がまだ実行されていません。その結果、空のウォッチャーが生成されて @watcher が非 nil に固定され、以後の prepend_view_path ごとに Dir[*globs] + File.mtime による全スキャンが走るという問題が生じていました。
PRが示す極端な例として、prepend_view_path が10回呼ばれる場合の動作を以下に示します。修正前は10回のフルリビルドが発生していたのに対し、修正後はゼロになります:
| ステップ | 修正前 | 修正後 |
|---|---|---|
起動早期の updated?
|
なし(安価) | なし |
prepend_view_path ×10 |
10回リビルド | 0回リビルド |
最初のリクエストの updated?
|
リビルドなし | 1回ビルド |
Shopify の Rails モノレポで計測したところ、小規模なワークロードでの所要時間は 5.16 秒から 3.66 秒へと 29% 削減されています。
技術的な変更
この修正は、ウォッチャーフックの登録タイミングの変更と、build_watcher への空チェック追加という2点で構成されています。
フックの登録場所の移動
ViewReloader のコンストラクタ内で行われていた file_system_resolver_hooks へのフック登録が、actionview/lib/action_view/railtie.rb の初期化処理へと移動されました。
変更前(cache_expiry.rb内):
def initialize(watcher:, &block)
@mutex = Mutex.new
@watcher_class = watcher
@watched_dirs = nil
@watcher = nil
@previous_change = false
ActionView::PathRegistry.file_system_resolver_hooks << method(:rebuild_watcher)
end
変更後(railtie.rb内):
unless enable_caching
view_reloader = ActionView::CacheExpiry::ViewReloader.new(watcher: app.config.file_watcher)
ActionView::PathRegistry.file_system_resolver_hooks << view_reloader.method(:rebuild_watcher)
app.reloaders << view_reloader
app.reloader.to_run do
rebuild_watcher のパブリック化と build_watcher への空チェック追加
これまでプライベートメソッドだった rebuild_watcher が、外部から method(:rebuild_watcher) で参照できるようパブリックに昇格されました。また、build_watcher 内に「ビューパスが空でかつウォッチャーが未構築の場合はスキップする」ガード節が追加されています。
def rebuild_watcher
return unless @watcher
build_watcher
end
# ...
def build_watcher
@mutex.synchronize do
new_dirs = dirs_to_watch
# Skip the build entirely if there are no view paths to watch and we have not built a watcher yet.
return if new_dirs.empty? && @watcher.nil?
old_watcher = @watcher
# ...
end
end
さらに updated? の戻り値も @watcher.updated? から @watcher&.updated? に変更され、ウォッチャーが nil のままでも安全に false を返せるようになっています。
今回の変更に対応するテストとして actionview/test/template/cache_expiry_test.rb が新規追加されました。FileUpdateChecker の代わりに CountingWatcher という軽量なスタブクラスを用い、ウォッチャーが実際に initialize された回数を計測することで、生成タイミングを精密に検証できる設計になっています。
設計判断
フック登録をコンストラクタの外へ出すという方針が採用されました。
rebuild_watcher を ViewReloader のコンストラクタではなく Railtie の初期化時にフックへ登録することで、インスタンスの生成と「パス変更への反応」を切り離しています。ウォッチャーはパスが実際に登録されるまで構築されず、rebuild_watcher が呼ばれても @watcher が nil であれば即座に返るため、誤ったタイミングでの早期構築が構造的に防がれます。
また build_watcher 内の return if new_dirs.empty? && @watcher.nil? は、「ウォッチャーが一度でも構築された後なら空のパスセットでも再構築を許容する」という意図を持ちます。既に実体化したウォッチャーを空ディレクトリで再構築するケース(パスが削除されるなど)を考慮した、防御的なガードです。
updated? での @watcher&.updated? の採用は、「ウォッチャーが nil の場合は更新なし(false)とみなす」というセマンティクスを明示し、nil チェックをメソッド呼び出し側に散在させない設計です。
本修正は、#51308 が開けてしまった「空のウォッチャーが誤って固定される」穴を、フック登録タイミングの調整と最小限のガード節によって塞いだ変更です。既存の遅延構築の設計思想を継承しつつ、Spring のようなプリローダー環境でも正しく機能するよう補強されており、変更範囲を最小化しながら確実な修正を実現しています。