おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

TurbolinksからTurboへの移行

弊社サービスである savanna.io はずっとTurbolinksStimulusで開発してきたのですが、この度 Hotwireがリリースされた*1のでTurbolinks部分をTurboに置き換えてみました。その際のやったことやハマったことのメモを残しておきます。メモ書きなので雑なのはご容赦ください。

前提

webpackerを使ってます。assets pipeline派や素のwebpackを利用している人は適宜読み替えてください

TurbolinksのアンインストールとTurboのインストール

  • turbo-railsをGemfileに追加してbundle install
  • ./bin/rails turbo:installをする

で問題なくいけるのであればそれで。turbo:installが失敗したらturbo-rails/turbo_with_webpacker.rb と同等のことを手動でやればOKです。savanna.io ではTypeScriptを利用しているためうまくいきませんでした(turbo:install はapp/javascript/application.jsがあることを前提としている)。

turbolinksをturboに置換する

document.addEventListener('turbolinks:load', ...のようにしている箇所をdocument.addEventListener('turbo:load', ...に置換します。

turbolinks-cache-controlなどは、Turboのドキュメントには書いてありませんがTurboのコードをみたらturbolinksの箇所をturboに書き換えるだけで問題なさそうだったのでそのようにしたら動作しました。多分turbolinksでできることはturboのドキュメントに書いてなくてもできるんじゃないかな…(未確認)。

OAuthログインの対応

savanna.ioはOAuthによるログインに対応しています。omniauthのCSRF脆弱性に対応できるように、しばらく前から次のようにPOSTを利用するようにしていました。

= link_to 'twitterにログイン', '/auth/twitter', method: :post

このコードで生成したリンクにアクセスするとうまく遷移できません。これは、Turboからはformも非同期対象になったのが原因です。twitterのログイン用URLをfetchしようとしてpreflightリクエストでエラーになっていました。

Turboはdata-turbo=false属性をつけることで特定のリンクやformをTurbo非対応にすることができます。

しかし、上記のlink_toにmethodオプションをつけたものについてはうまくdata-turbo=false属性をつけることができません。methodオプションがついているリンクをクリックするとrails-ujsが自動的にformを作りsubmitする、という実装になっています。つまり自動で生成されるformに対してdata-turbo=falseを付ける必要があるのですが、これを行うためのAPIがありません。

なのでlink_toをbutton_toに変更して対応しました。

= button_to('/auth/twitter') { 'Twitterでログイン'  }

バリデーションエラー時の対応

TurboはTurbolinksでは未対応だったformのsubmitに対応しています。data-remote=trueなformでなくとも自動でfetchを使い非同期でPOSTします。

バリデーションエラー時に422などのステータスコードを返すと、URLはそのままの状態で画面の差し替えが行われます。つまり普通に(scaffoldで生成したようなアクションの形で)バリデーションエラーのメッセージを含む画面を描画できるようになりました。

Turbo方式とTurbolinks方式を混在させる

savanna.io では ajax_error_rendererというgemを使いSJRでエラーメッセージを差し込む、ということをやっているのでそれを普通のエラーメッセージ用のHTMLを返すように変更する必要があります。

すべてのアクションを一気に変更するのは大変なので、徐々に切り替えられるように、Turbo方式とTurbolinks方式を混在させられるような仕組みを作りました。

Turbolinksはrails-ujsによるajaxでPOSTをする想定です。そのときredirectをしたらTurbolinks.visit()を実行するようにサーバからレスポンスを返します。これをturbolinks-railsというgemが実現していました。次のコードは、turbolinks-railsと同等のことをTurboで実現するようにしています。このように書くと、data-remote=trueなformからPOSTしたときはturbolinksの挙動で、それ以外はTurboの挙動になります。

# ApplicationController
  def redirect_to(...)
    super.tap do
      visit_location_with_turbo(location) if request.xhr? && !request.get? && !request.format.turbo_stream?
    end
  end

  def visit_location_with_turbo(location)
    visit_options = { action: 'replace' }

    script = []
    script << 'Turbo.clearCache()'
    script << "Turbo.visit(#{location.to_json}, #{visit_options.to_json})"

    self.status = 200
    self.response_body = script.join("\n")
    response.content_type = 'text/javascript'
    response.headers['X-Xhr-Redirect'] = location
  end

request.format.turbo_stream?がtrueのときはTurboからのリクエスト経由のリダイレクトなのでなにもしない(通常のredirect_to)ようにしています。

// application.ts
import { Turbo } from '@hotwired/turbo-rails'
window.Turbo = Turbo

Turboはwebpack環境ではglobalではないので明示的にglobal変数として扱えるようにする必要があります。

Turboではバリデーションエラー時にturbo:loadが発火しない

これであとは頑張ってバリデーションエラー時の処理を置き換えていくだけ…と思いきやそんなことはありません><バリデーションエラー時にturbo:loadが発火しないという現象に遭遇しました。https://github.com/hotwired/turbo/issues/85 を見た限りこれは仕様とのことです…。

savanna.ioではturbo:loadがすべてのページ表示のタイミングで発火するのを期待しているコードがあったので、検討した結果、上記の「Turbo方式とTurbolinks方式を混在させる」で書いたやり方は不採用にしました*2

結局、turbo-streamを利用するとajax_error_rendererと使用感がほぼ同じになるのでturbo-streamでエラーメッセージを差し込むようにしました。次のようにしてrender_errors_by_turbo_streamを呼び出すと<div id="erorr"></div>にエラーメッセージが差し込まれます。

  def render_errors_by_turbo_stream(model:)
    render turbo_stream: turbo_stream.update(:error, partial: 'errors', locals: { model: model }),
           status: :unprocessable_entity
  end
- unless model.errors.empty?
  #error_explanation.alert.alert-danger.error-messages
    %ul
      - model.errors.full_messages.each do |msg|
        %li= msg

[コラム]バリデーションエラー時のリロードの処理

バリデーションエラー時にブラウザをリロードしたときの挙動はTurbo使用時とそうでないときとで違いがあります。Turbo使用時はformのページがリロードされます。Turbo未使用時は「フォームの内容を再送しますか?」といったダイアログが表示されます。

これはHistroy APIを利用している以上仕方がない*3ですが、Turbolinksのときと比べるとだいぶ改善された感じがあります。Turbolinks利用時かつdata-remote=trueなしなフォームのときは、newアクションのformにいるはずがリロードするとindexアクションにアクセスしてしまう、という事が起きていました。

参考: Turbolinks5でPOSTするときはajax経由のほうが良いのかも - おもしろwebサービス開発日記チラシの裏

data-disable-withに対応する

turbo-streamを使うと動的にエラーメッセージを差し込むことができてめでたしめでたし、かと思いきや、バリデーションエラー時にsubmitボタンがdisableのまま、という状況に遭遇しました。

form_withなどでajaxを利用してPOSTしたとき、ajax終了時に自動でdisable状態を解除するようにrails-ujsで定義されています。turboはrails-ujsを利用していないので、独自で対応する必要があります。次のように、フォームsubmit後のバリデーションエラーで、rails-ujsのdisable状態を解除するためのメソッドを呼び出すようにしました。

document.addEventListener('turbo:submit-end', (event: Event) => {
  if (!event.detail.success) {
    Rails.enableElement(event.detail.formSubmission.formElement)
  }
})

assets precompileの対象から外す

turbo-railsは今のところassets precompileできるなら、turboをその対象として自動で挿入するという仕様になっています。savanna.io は基本webpackerなのですがassetsを使っているgem(administrate)があるのでassets pipelineはオフにしていません。そしてturbo-railsが提供しているjsはES6の書き方なので、本番デプロイ時にuglifierによる圧縮処理で失敗しました。そもそもassetsとして提供されているjsは使わないので、次のようにして対処しました。

# config/application.rb

config.after_initialize do
  config.assets.precompile -= ['turbo']
end

その他turbolinksやrails-ujsに依存している処理を書き換える

あとは細かい変更なので概要だけ書いておきます。

  • turbolinks化でgoogle-analyticsを使うために google-analytics-turbolinkというgemを使っていたのをやめた
  • 「保存されていない変更があります。移動してよろしいですか?」を実装するためにrails-ujsのイベントをhookしていたのをturboのイベントにフックするようにした

まとめ

  • ここまで見てきたように、TurbolinksからTurboの移行に関してはそれなりにハマりどころがあります
  • ただ明らかにTurbolinks時代から改善が進んでいて体験が良くなっているので、気になっている人は一度試してみるのをおすすめします

*1:厳密には現時点で7.0.0-beta.4です

*2:便利に使う人もいるかと思うので書き残しています

*3:History APIはGETしか対応していない