Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails UJS・Turbolinks -> Turboアップグレードガイド(翻訳)

概要

MITライセンスに基づいて翻訳・公開いたします。

hotwired/turbo-rails - GitHub

Rails UJS・Turbolinks -> Turboアップグレードガイド(翻訳)

Turboは、従来Rails UJSが行っていたリンク変更やフォーム送信をXMLHttpRequestsに変える機能を置き換えます。Rails UJSおよびTurbolinksからTurboへの移行を完了するには、アプリケーションのconfig/application.rbconfig.action_view.form_with_generates_remote_forms = falseを設定する必要があります。しかし、すべてのアプリケーションが一挙に移行可能とは限りません。また、Rails UJSをTurboと共存させる必要が生じることもあるでしょう。そのために必要な手順を以下に示します。

1. Rails UJSでTurbo互換のセレクタを使うようにする

Rails UJSは、Railsフレームワークで直接提供されるか、さもなければ古いjquery-ujsプラグインによって提供されます。どちらもそのままにできますが、Turboと互換性のあるバージョンにアップグレードするか(#42476を参照)、アプリで必要なJavaScriptファイルをベンダリングして自分で調整する必要があります。

訳注

Rails UJSで書かれたアプリケーションをHotwireの新しいTurboフレームワークに移行する場合、移行の間(あるいは今後もずっと)TurboとUJSを共存させたい場合もあります。そのためには、フォーム送信の扱いを区別する手段が必要です。フォームでdata-turbo=trueを指定すると、Rails UJSはそのフォームを処理せずにTurboに処理を任せるようになります。
#42476より

2. Gemfile内のturbolinks gemをturbo-railsに置き換える

Gemfile内のgem 'turbolinks'を消し去ってgem 'turbo-rails'に置き換えたいことでしょう。しかし、UJSが発行する古いスタイルのXMLHttpRequestsがTurboでも動くようにするためには、古いTurbolinksの振る舞いを修正して、それらのリクエストをHTTP 302互換にする必要があります(Turbolinks呼び出しをTurbo呼び出しにする)。

まず、app/controllers/concerns/turbo/redirection.rbファイルを追加する必要があります。このconcernをApplicationController内でTurbo::Redirectionとしてincludeしてください。

module Turbo
  module Redirection
    extend ActiveSupport::Concern

    def redirect_to(url = {}, options = {})
      turbo = options.delete(:turbo)

      super.tap do
        if turbo != false && request.xhr? && !request.get?
          visit_location_with_turbo(location, turbo)
        end
      end
    end

    private
      def visit_location_with_turbo(location, action)
        visit_options = {
          action: action.to_s == "advance" ? 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
  end
end

次に、test/helpers/turbo_assertions_helper.rbファイルを追加する必要もあります。このヘルパーをActionDispatch::IntegrationTestincludeしてください。

module TurboAssertionsHelper
  TURBO_VISIT = /Turbo\.visit\("([^"]+)", {"action":"([^"]+)"}\)/

  def assert_redirected_to(options = {}, message = nil)
    if turbo_request?
      assert_turbo_visited(options, message)
    else
      super
    end
  end

  def assert_turbo_visited(options = {}, message = nil)
    assert_response(:ok, message)
    assert_equal("text/javascript", response.try(:media_type) || response.content_type)

    visit_location, _ = turbo_visit_location_and_action

    redirect_is       = normalize_argument_to_redirection(visit_location)
    redirect_expected = normalize_argument_to_redirection(options)

    message ||= "Expected response to be a Turbo visit to <#{redirect_expected}> but was a visit to <#{redirect_is}>"
    assert_operator redirect_expected, :===, redirect_is, message
  end

  # 「Content-Typeがtext/javascriptの非GETリクエスト」かどうかという
  # 簡単なヒューリスティックでTurbolinksのリクエストを検出する
  #
  # 技術的にはTurbolinks-Referrerリクエストヘッダが設定されていることも
  # チェックするが、そのためには`turbo:`オプションを指定して
  # POSTやPATCHなどのテストメソッドのヘッダーをオーバーライドして渡す
  # 必要がある
  #
  # `request.xhr?`チェックは利用できない
  # (X-Requested-Withヘッダーは、後続のリクエストで漏洩しないよう
  # コントローラのアクション実行後にクリアされるため)
  def turbo_request?
    !request.get? && (response.try(:media_type) || response.content_type) == "text/javascript"
  end

  def turbo_visit_location_and_action
    if response.body =~ TURBO_VISIT
      [ $1, $2 ]
    end
  end
end

3. packファイルに含まれるTurbolinksをTurboに置き換える

おそらくpackファイルにrequire("turbolinks").start()のような記述があると思いますが、これをimport "@hotwired/turbo-rails"に変更する必要があります。Turboはインポート時に自動的に起動されてwindow.Turboに割り当てられるので、何も起動する必要はありません。

4. Turbolinks名前空間をすべてTurbo名前空間に置き換える

  • document.addEventListener("turbolinks:before-cache" ...)のようなイベント名にあるTurblinks名前空間は、Turboのturbo:before-cacheに置き換える必要があります。

  • 同様に、Turbolinks.visit呼び出しもTurbo.visitに置き換える必要があります。

  • DOM要素のdata-turbolinks-actionなどの属性も、data-turbo-actionのように置き換える必要があります。

  • data: { turbolinks: false }もすべてdata: { turbo: false }に置き換えることを忘れずに。

5. オプション: モバイルアダプタ向けの後方互換shimを提供する

Turbolinksモバイルアダプタを用いて構築したネイティブアプリも移行する必要がある場合は、Turbolinksの呼び出しをTurbo呼び出しに変換するshimが必要になるでしょう。Basecampでは以下のshimが必要でした。

// モバイルアプリ向けの互換性shim
window.Turbolinks = {
  visit: Turbo.visit,

  controller: {
    isDeprecatedAdapter(adapter) {
      return typeof adapter.visitProposedToLocation !== "function"
    },

    startVisitToLocationWithAction(location, action, restorationIdentifier) {
      window.Turbo.navigator.startVisit(location, restorationIdentifier, { action })
    },

    get restorationIdentifier() {
      return window.Turbo.navigator.restorationIdentifier
    },

    get adapter() {
      return window.Turbo.navigator.adapter
    },

    set adapter(adapter) {
      if (this.isDeprecatedAdapter(adapter)) {
        // 古いモバイルアダプタはvisitProposedToLocation()をサポートしない
        adapter.visitProposedToLocation = function(location, options) {
          adapter.visitProposedToLocationWithAction(location, options.action)
        }

        // 古いモバイルアダプタはvisit.location.absoluteURLを利用するが、
        // TurboではLocationクラスを廃止してDOM URL APIに変えたので利用できない
        const adapterVisitStarted = adapter.visitStarted
        adapter.visitStarted = function(visit) {
          Object.defineProperties(visit.location, {
            absoluteURL: {
              configurable: true,
              get() { return this.toString() }
            }
          })

          adapter.currentVisit = visit
          adapterVisitStarted(visit)
        }
      }

      window.Turbo.registerAdapter(adapter)
    }
  }
}

// デスクトップアプリによってrequireされる
document.addEventListener("turbo:load", function() {
  const event = new CustomEvent("turbolinks:load", { bubbles: true })
  document.documentElement.dispatchEvent(event)
})

関連記事

Rails 7: importmap-rails gem README(翻訳)

速報: Basecampがリリースした「Hotwire」の概要


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。