Stimulusをlazyにしても全コントローラを読む理由
importmapのpin_all_fromはデフォルトがpreload: true。eager → lazyの1行と一緒にpreload: falseまで変え、名前・呼ぶ順番・最初のクリックを確かめる
importmap で Stimulus を使っている Rails アプリで、eagerLoadControllersFrom を lazyLoadControllersFrom に変えようとしている人向けの記事。読み終わると、その1行だけではダウンロードが減らない理由がわかり、切り替え前に見る6つを確認してから進められる。
先に結論。pin_all_from はデフォルトが preload: true だ。だから lazy にしても全コントローラが <link rel="modulepreload"> として HTML に出て、ブラウザはそれを全部取りに行く。config/importmap.rb に preload: false まで入れて、やっと減る。
ログイン画面がコントローラを131本受け取っていた
仕事のプロジェクト(契約管理 SaaS)で、メールを一斉送信した直後に ALB が 502 を数万件返した。なぜ CPU 21% で落ちたのかは Pumaのスロット枯渇とは?CPU 21%で502が6万件出た理由 に書いた。ここではそのスロットを何が埋めたのかを書く。
ALB のアクセスログ約69万行をパス別に数えたら、こうなった。
/renewals メール内のリンク 約8%
/login 未ログインで302 → ログイン 約4%
/assets/** JS モジュール 約88%
ログイン画面なのに、組織管理や会計画面のコントローラ JS まで受け取っていた。静的ファイルを Puma が直接返す構成(public_file_server)だったので、そのファイル1つ1つが Puma のスロットを1つずつ使っていた。
犯人は app/javascript/controllers/index.js の1行。stimulus-rails を入れたときに生成されるデフォルトそのままだ。
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
eager と lazy は何が違うのか
stimulus-rails 1.3.4 の stimulus-loading.js を開くと、どちらも短い。
eagerLoadControllersFrom は、ページの importmap から controllers/…_controller で終わるキーを全部拾って import() する。その画面に data-controller が1つもなくても関係ない。
lazyLoadControllersFrom は、いま DOM にある data-controller の値だけを import() し、その後は MutationObserver(DOM の変化を知らせてくれるブラウザの API)で新しく出てくる要素を見張る。
ここまで見ると1行変えれば終わりに見える。最初は自分もそう思っていた。
1行では減らなかった — modulepreload
javascript_importmap_tags は importmap を出すほかに、もう1つ仕事をする。preload が有効な pin ごとに <link rel="modulepreload"> を出す。ブラウザはこのリンクを見ると、誰が import するかに関係なくそのモジュールを先に取りに行く。
そして pin_all_from のデフォルトが preload: true だ。importmap-rails 2.2.2 で、コントローラ3つのフォルダを pin_all_from して preload 一覧を実際に出してみた。
preload: true (デフォルト)
modulepreload: application.js, stimulus-loading.js,
channel_controller.js, file-dropzone_controller.js, hello_controller.js
preload: false
modulepreload: application.js, stimulus-loading.js
importmap のキー: コントローラ3つはそのまま
lazy にしてこの設定をそのままにすると、JS は import しないのに HTML が「全部先に取っておいて」と言っている状態になる。だから直す場所は2つある。
# config/importmap.rb
pin_all_from "app/javascript/controllers", under: "controllers", preload: false
preload: false でも importmap のキーは残る。lazy が import() するアドレスはそのままなので、動きは変わらない。消えるのは先読みだけだ。
切り替え前に見るもの 1 — 名前がファイル名と正確に合っているか
eager はファイル名から名前を作る。_ を - に、/ を -- に。lazy は逆に名前からファイル名を作る。-- を / に、- を _ に。
お互いに逆変換なので、片方向でしか合わないファイルができる。ファイル名にハイフンが入った file-dropzone_controller.js がそうだ。2つの関数をそのまま node で動かしてみた。
file-dropzone_controller
eager が登録する名前 file-dropzone → 動く
lazy が探すファイル file_dropzone_controller → ない
eager のときはたまたま動いていたコントローラが、lazy にした瞬間に何も言わずに付かなくなる。切り替える前にまずこれを探す。
find app/javascript/controllers -name '*-*_controller.js'
2 — 失敗が静か
lazy の import() が失敗すると、console.error("Failed to autoload controller: …") が1行出るだけ。画面は普通に表示され、そのボタンだけが効かない。
特に危ないのはデプロイ直後に開いたままのタブだ。ページに埋め込まれた importmap は古いダイジェスト(ファイル名に付く中身のハッシュ)のアドレスを指している。新しいイメージに古いファイルがなければ、モーダルを開いた瞬間に 404 になる。importmap タグには data-turbo-track="reload" が付いているので Turbo で別画面に移ればリロードされるが、同じ画面でモーダルを開くだけならその機会がない。
3 — 手で register するコードより後ろに置く
application.register("dropzone", …) のように手で登録する行が混ざっているなら、lazyLoadControllersFrom はそれより後で呼ぶ。
stimulus-loading は canRegisterController で登録済みの名前を弾く。でも lazy を先に呼ぶと、その時点ではまだ登録前なので fetch が飛ぶ。取ってきたモジュールは登録時にもう一度弾かれて捨てられるので、リクエストが1回無駄になる。
4 — 後から付く要素は心配しなくていい
Issue を最初に書いたとき一番心配していたのがこれだった。Turbo Frame の差し替え、Turbo Stream の append、モーダルの挿入、JS で setAttribute("data-controller", …) するコード。
ソースを見ると、MutationObserver が childList と subtree に加えて attributeFilter: ["data-controller"] で属性の変化まで見ている。4つとも拾える。ただ仕組みが正しいことと実際の画面が動くことは別の話なので、その画面は手で1回ずつ触って確かめた。
5 — 最初のクリックが空振りすることがある
lazy は要素が出てきたあと import → register → connect() の順に進む。data-action が効くのは connect() のとき。その前のクリックは何も起きない。
キャッシュがあれば数 ms で気づかない。キャッシュがなければネットワーク往復1回分だ。モーダルが開いた瞬間に押すボタンや連打するボタンは、キャッシュを消して押してみる。
6 — リクエスト数は「その画面で使う分」に減るだけ
lazy はリクエストをなくすわけではない。画面にコントローラが20個あればリクエストも20本。だから切り替える前に2つ見る。HTTP/2 以上か、ダイジェスト付きファイルに長い Cache-Control が付いているか。
# config/environments/production.rb
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=31536000, immutable"
}
今回の件は、メールのリンクを押した人もスキャナーも毎回新しいセッションで来たので、キャッシュがまったく効かなかった。だから88%になった。根本は静的ファイルを Puma ではなく CDN や前段のプロキシに返させること。lazy はそれまでの量を減らす手だ。
それと、そのプロジェクトは spec/system が0本だった。上の 2・4・5 は画面を触らないとわからない種類なので、E2E なしで切り替えるのは「手動 QA で守る」と決めるのと同じだ。どの画面を触ったかを PR に書いておこう。
バンドラー(Vite・esbuild)なら話が変わる
importmap はファイル1つがリクエスト1本なので、eager か lazy かがそのままリクエスト数になる。バンドラーなら eager は1つのバンドルに全部入ってリクエストは1本、問題はサイズになる。lazy はコード分割のチャンクになる。Vite の import.meta.glob はデフォルトが lazy(関数を返す)で、{ eager: true } を付けると静的 import になる。
選ぶ基準はこうしている。コントローラが数十本以下で、どの画面でも似たように使うなら eager のままでいい。画面ごとに使うコントローラが分かれていて、ログインや LP のようなログイン前のページがトラフィックを受けるなら、lazy + preload: false だ。
古い Rails アプリのフロントのアセットがどこからどれだけ出ているか、静的ファイルを Puma から切り離すところまで一緒に見てほしければ、negabaro に相談してもらってかまわない。
eager・lazy・preload — ログイン画面で何を受け取るか
| eagerLoad | lazyLoad | lazyLoad + preload: false | |
|---|---|---|---|
| import するもの | importmap のコントローラ全部 | DOM の data-controller だけ | DOM の data-controller だけ |
| modulepreload リンク | 全部(pin_all_from のデフォルト) | 全部(そのまま) | application・stimulus-loading だけ |
| ログイン画面が受け取るコントローラ | 全部 | 全部 | その画面で使うものだけ |
| 名前 ↔ ファイル | ファイル名から名前を作る | 名前からファイル名を作る(- → _) | 同じ |
切り替え前のチェックリスト
| 確認 | どうやって |
|---|---|
| preload: false | config/importmap.rb の pin_all_from に付けたか |
| ハイフン入りファイル名 | find app/javascript/controllers -name '*-*_controller.js' が0行か |
| 呼ぶ順番 | lazyLoadControllersFrom が手書きの application.register より後か |
| 静かな失敗 | デプロイ直後に開いたままのタブ・モーダルで、コンソールに Failed to autoload controller が出ないか |
| 最初のクリック | キャッシュを消して、モーダル直後のボタン・連打するボタンを押してみたか |
| キャッシュ・HTTP/2 | ダイジェスト付きファイルに長い Cache-Control、HTTP/2 以上か |
| E2E | なければ手で触った画面を PR に書いたか |
キーポイント
controllers/index.jsのeagerLoadControllersFromをlazyLoadControllersFromに
config/importmap.rbのpin_all_from "app/javascript/controllers"にpreload: false
ファイル名にハイフンが入ったコントローラを探してアンダースコアに直す
lazyLoadControllersFromを手書きのapplication.registerより後ろに
キャッシュを消してモーダル・Turbo Frame・連打ボタンを手で押してみる
ページのソースでmodulepreloadリンクがapplication・stimulus-loadingだけになったか見る