はじめに
個人サービスでVue.js 3 + TypeScriptのフロントエンドをVite経由でビルドしており、RailsとViteの統合にはvite_rails(vite_rubyベース)を使っていましたが、rails_vite(README曰く"No proxy, no config duplication, no magic")へ移行したので、その時のハマりどころ等をメモ📝
なお、rails_vite作者本人が書いたEvil Martiansの解説記事によると、rails_viteにはapp/assets/builds/にスタブファイルを置いてPropshaftと共存する「jsbundling mode」と、rails_vite gem自体をインストールしてvite_tagsヘルパーを使う「gem mode」の2パターンがありますが、今回は後者のgem modeを採用しています。
本題
vite_railsとrails_viteの設計思想の違い
移行にあたって最初に整理したのが、両者の設計思想の違いです。

主な違いは以下の3点です。
| 観点 | vite_rails | rails_vite |
|---|---|---|
| 開発サーバー接続 | Rackプロキシ経由(同一オリジン) | プロキシなし(ブラウザが直接devサーバーへ接続、別オリジン) |
| 設定ファイル | config/vite.json(Ruby側)+ vite.config.ts(JS側)の二重管理 |
vite.config.tsのみ。Ruby側は起動時に書き出されるtmp/rails-vite.jsonを読んで自動同期 |
| 依存gem | rack-proxy / dry-cli / mutex_mなど |
railtiesのみ |
設定ファイルの二重管理がなくなる点は、地味ですがメンテナンスコストの削減として大きいポイントでした。
移行作業自体はGemfileの入れ替えとvite.config.tsの設定移植が中心で、ローカルでの動作確認では大きな問題は起きませんでした。ただ、View側とCSP周りで2つほど設計差分に起因する対応が必要になりました。
1つ目はViewヘルパーの実装方式の違いです。vite_railsのViteRails::TagHelpersはRails標準のjavascript_include_tag/stylesheet_link_tag(AssetTagHelper)に処理を委譲していましたが、rails_viteのRailsVite::TagHelperはtag.script/tag.linkを自前で組み立てる実装になっています。そのため、Rails標準のnonce: trueという糖衣構文(AssetTagHelper内部でcontent_security_policy_nonceを自動解決する仕組み)が効かなくなりました。
先述のEvil Martiansの記事では「gem modeはCSP nonce対応がある」と書かれていて、実際vite_tagsはnonceの値を渡せば正しく<script nonce="...">を出力してくれます。ただしそれは「nonceの値を明示的に渡せば効く」という意味であって、Railsのnonce: true(AssetTagHelperが内部でcontent_security_policy_nonceを自動解決してくれる糖衣構文)がそのまま動くわけではありませんでした。このニュアンスの違いに気づかず最初はnonce: trueのまま動かしてハマったので、地味に注意が必要なポイントです。呼び出し側の記法を変えたくなかったので、自前のVitePackHelperラッパー内でnonce: trueを検知して実際のnonce値に変換する形で吸収しています(詳細は実装例セクション参照)。
2つ目はCSPです。プロキシがなくなり、開発環境ではViteのdevサーバーが別オリジンになったことで、devサーバーから配信される画像がCSPのimg-srcでブロックされるようになりました。RailsVite.dev_server_csp_sourceというdevサーバーのオリジンを動的に解決するヘルパーが用意されているので、それを使ってimg-srcに追加することで解消しました。
ここまではローカルでの検証で気づけた範囲でしたが、実際にCIを回してみると想定していなかった問題が2つ見つかりました。
CIで発覚した想定外の問題
1. db:test:prepareがNode未セットアップのジョブで失敗する
rails_viteはdb:test:prepareにRakeタスクの前提条件としてvite:build(さらにその前提としてvite:install = pnpm install)を自動フックする仕組みを持っています。Dogfeedsでは「system spec以外を高速に回す」目的で、あえてNode/pnpmをセットアップしていないCIジョブがあり、そのジョブでpnpm installが見つからずdb:test:prepare自体が失敗しました。
調べたところSKIP_VITE_BUILD=1という環境変数でこのRakeタスクのフックを丸ごとスキップできることがわかり、それで解決しました。このジョブのrequest specはもともとskip_assetsという共有RSpecコンテキストでVitePackHelperをスタブし、ビルド済みアセットに依存しない作りになっていたため、アセットビルド自体を省略しても実害はありませんでした。
vite:build hooks into assets:precompile and test:prepare automatically. Skip with SKIP_VITE_BUILD=1. https://github.com/skryukov/rails_vite#rake-tasks
2. VRTで11ページ分の見た目が丸ごと崩れる
ここが今回のメインの発見です。Visual Regression Test(VRT)を回したところ、「AIチャット一覧」「フィード詳細」など11ページで大きな差分が検出されました。差分画像を見ると、CSSがほぼ全く効いていないような崩れ方で、Tailwindのflexレイアウトが効かず全要素が縦積み・全幅になっていました。
原因は2段階ありました。
原因1(小さい方): rails_viteが内部で利用しているrails-vite-pluginが、build.assetsInlineLimitを未指定時に0で強制する実装になっていました。これにより、4KB未満のfavicon.ico(Viteのデフォルト閾値4096バイトの内側にあり、以前はbase64インライン化されていた)が別ファイル読み込みになり、全ページ共通のNavigationBarコンポーネントの描画タイミングが変わってVRTの差分要因の一部になっていました。assetsInlineLimit: 4096(Viteのデフォルト値)を明示することで解消しました。
assetsInlineLimit: userConfig.build?.assetsInlineLimit ?? 0,
原因2(本命): assetsInlineLimitを直しても差分が残るページがあったため、with_playwright_page(capybara-playwright-driver経由で生のPlaywright Pageオブジェクトを取得できる仕組み)を使って、ブラウザのconsoleログ・レスポンスステータス・DOM要素のgetComputedStyle()を直接調べるデバッグ用の一時specを書いて調査しました。その結果、CSSファイル自体は200 OKで正しく配信されているのに、TemplateBase.vue(全ページ共通のレイアウトラッパー、<style scoped>でflexboxレイアウトを定義)のdisplay: flexが全く適用されていないことがわかりました。
manifest.jsonを直接見ると、TemplateBaseは複数のページエントリーから共通でimportされる関係でRollupによって独立したchunkに分割されており、そのchunk自身が"css": ["TemplateBase-xxxx.css"]という独自のCSSファイルを持っていました。ところがrails_viteのRailsVite::Manifest#resolve_cssの実装は、エントリー自身のcssフィールドしか見ておらず、imports(modulepreload用には再帰的に辿っている)配下のchunkが持つCSSを一切収集していませんでした。つまり複数ページで共有されるVueコンポーネントのscoped CSSが、本番ビルド(manifestベースのレンダリング)だと構造的に読み込まれないバグでした。

devサーバー経由の開発時レンダリングでは各SFCのCSSが個別に注入されるため問題は再現せず、本番ビルド(manifestベース)でのみ顕在化する類のバグでした。開発中には気づけない厄介なパターンです。
旧vite_rubyのViteRuby::Manifest#resolve_entriesのソースを確認したところ、stylesheets: (entries + imports).flat_map { |entry| entry["css"] }.compact.uniqと、エントリー自身とimports両方からCSSを集める実装になっていました。rails_vite側の実装漏れであり、意図的な仕様変更ではなさそうに感じたので対応のPRを作成しました。
実装例・コード
nonce: trueを吸収するVitePackHelper
# app/helpers/vite_pack_helper.rb module VitePackHelper # rails_viteのTagHelperはRails標準のnonce: true糖衣構文を解釈しないため、 # 呼び出し側のインターフェースを変えずに済むよう、ここで実際のnonce値に変換する def vite_javascript_tag(*names, nonce: false, **options) options[:nonce] = content_security_policy_nonce if nonce super(*names, **options) end end
devサーバーのオリジンをCSPのimg-srcに追加
# config/initializers/content_security_policy.rb Rails.application.config.content_security_policy do |policy| # rails_viteはプロキシを持たないため、開発環境ではViteのdevサーバー(別オリジン)から # 画像が配信される。CSPで明示的に許可しないとimg-srcでブロックされる policy.img_src(:self, :data, *(RailsVite.dev_server_csp_source if Rails.env.development?)) end
db:test:prepareのvite:buildフックをスキップ
# .github/workflows/ci.yml(Node未セットアップのジョブ) - name: Prepare test database run: bin/rails db:test:prepare env: SKIP_VITE_BUILD: "1" # pnpmをセットアップしていないジョブのためvite:buildフックを無効化
resolve_cssに対するModule#prependによる暫定パッチ
upstreamにPRがmergeされるまでの間、以下のパッチで先に問題を解消しました。
# config/initializers/rails_vite_patch.rb module RailsViteManifestCssPatch # imports配下の共有chunkが持つCSSも再帰的に集めるようにする # upstream issue: https://github.com/skryukov/rails_vite/pull/40 def resolve_css(names) names.flat_map { |name| collect_css_recursively(find_entry(name)) }.uniq end private def collect_css_recursively(entry, visited = Set.new) return [] unless entry own_css = entry["css"] || [] imported_css = (entry["imports"] || []).flat_map do |import_key| next [] if visited.include?(import_key) visited << import_key collect_css_recursively(manifest_entries[import_key], visited) end own_css + imported_css end end Rails.application.config.to_prepare do RailsVite::Manifest.prepend(RailsViteManifestCssPatch) end
with_playwright_pageを使ったcomputed styleの直接検証
# spec/system/debug/template_base_css_spec.rb(調査用の一時spec) RSpec.describe "TemplateBaseのCSS適用確認", type: :system do include_context "login_as_user" it "TemplateBaseにdisplay: flexが適用されていること" do visit feed_bundles_path with_playwright_page do |page| # レスポンスステータスとconsoleエラーを直接確認し、CSS配信自体は正常であることを切り分ける page.on("console") { |msg| puts "[console:#{msg.type}] #{msg.text}" } display = page.eval_on_selector(".template-base", "el => getComputedStyle(el).display") expect(display).to eq("flex") end end end
getComputedStyle()を直接呼ぶことで、「CSSファイルは配信されているが適用されていない」という、devtoolsのNetworkタブだけでは見えにくい状態を機械的に検証できました。
おわりに
rails_vite、移行に少しハマりどころもありましたが、ハックが少なく素直にViteを利用できる感じが良いですね✨



