About

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() するアドレスはそのままなので、動きは変わらない。消えるのは先読みだけだ。

Same login page, three setupscontroller JS the browser fetcheseagerLoadpreload: true (default)every controller (131 on that project)lazyLoadpreload: true (unchanged)still every controller, via modulepreload linkslazyLoadpreload: falseonly what this page usesjavascript_importmap_tags prints <link rel=modulepreload> for every pin with preload: true.The browser fetches each one, even if no JS ever imports it.

切り替え前に見るもの 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 — ログイン画面で何を受け取るか

eagerLoadlazyLoadlazyLoad + preload: false
import するものimportmap のコントローラ全部DOM の data-controller だけDOM の data-controller だけ
modulepreload リンク全部(pin_all_from のデフォルト)全部(そのまま)application・stimulus-loading だけ
ログイン画面が受け取るコントローラ全部全部その画面で使うものだけ
名前 ↔ ファイルファイル名から名前を作る名前からファイル名を作る(- → _)同じ

切り替え前のチェックリスト

確認どうやって
preload: falseconfig/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 に書いたか

キーポイント

1

controllers/index.jsのeagerLoadControllersFromをlazyLoadControllersFromに

2

config/importmap.rbのpin_all_from "app/javascript/controllers"にpreload: false

3

ファイル名にハイフンが入ったコントローラを探してアンダースコアに直す

4

lazyLoadControllersFromを手書きのapplication.registerより後ろに

5

キャッシュを消してモーダル・Turbo Frame・連打ボタンを手で押してみる

6

ページのソースでmodulepreloadリンクがapplication・stimulus-loadingだけになったか見る

ユースケース

ログインやLPのようなログイン前の画面がコントローラJSを全部受け取っているとき Pumaが静的ファイルまで返していて、assetsのリクエストがスロットを食うとき lazyに変えたあと特定のコントローラだけ付かないとき
← password_digestカラムでDeviseのinspectが落ちる理由 Faradayのopen_timeoutは未設定だとtimeoutの値になる →