Turbo Frame のリンク先のリダイレクト対策
この記事における環境について:
- rails (8.0.2)
- turbo-rails (2.0.13)
- devise (4.9.4)
先ず背景事情の補足なのですが、この記事では "ログイン画面" を想定して話を進めていますが、これは devise の一般的な構成を利用してのことです。コントローラで before_action :authenticate_user! でログインが必須であるように設定された画面に、ログインしていないユーザのアクセスがあったとき、 devise はコントローラのアクションのまえに、ログイン画面へリダイレクトさせます。
問題発生: Turbo Frame のリンク先がリダイレクトすると "Content missing"
Rails アプリで Turbo Rails を利用して、ページの一部分だけを書き換えるために Turbo Frame を活用していると、(これは誰しもが通る道のようなのですが、)あるとき Turbo Frame リンクの遷移先が、サーバー側でリダイレクトを返すと問題になる場合があることに気が付くことになります。
例えばログインしなければ見えないページを表示している場合を考えます。その最中、有効期限切れなどでセッションが切れたとします。その状態から、表示されたままのページの Turbo Frame のリンクをクリックすると、その先はログインが必要な画面であるので、サーバはログイン画面へリダイレクトすることになるでしょう。
Turbo はレスポンスに置換用の <turbo-frame> があるものと期待しているので、それが存在しないログイン画面が返ってきた場合は例外になります。結果として "Content missing" というメッセージだけが、フレームに表示されてしまうことになります。それは望まれるものではありません。
つまり Turbo Frame のリクエストに対して、リダイレクトされてログイン画面が返ってきたならば、それをフレーム(=ページの一部)に置き換えようとするのではなく、ページ全体で置き換えてほしいのですが、どのように処理するのがよいでしょうか? というのが、今回取り上げたい話題です。
対処法(1): turbo-visit-control
まず最初に思い浮かんだ方法は、ログイン画面のページに turbo-visit-control という meta タグを置くことです。
<meta name="turbo-visit-control" content="reload">
これは公式のドキュメントでも “Breaking out” from a Frame という項目で紹介されているものでした。つまりこの機能は、まさに今回のような要望に応えるために提供されていると思えます。
万葉さんによる日本語訳 からの引用:
実際には
<turbo-frame>リクエストに対するレスポンスを、フルページナビゲーションの代わりとなる、効果的にフレームから"脱却"した、新しいページとして扱いたい場面もあるでしょう。セッションが有効期限切れなどで失われ、アプリケーションのログイン画面へリダイレクトする場合はその典型的な例です。この場合、Turbo はセッション切れエラーとして扱うよりもログイン画面を表示させた方が良いでしょう。
meta タグはそのまま書き入れたくなりますが、 Turbo Rails にはヘルパーがあり、 Turbo Rails の将来の変更可能性に対する備えという観点からもヘルパーを使った方がよいでしょう。このように、ログイン画面のテンプレートにこのヘルパーを置けば、対応は完了です。
<%= turbo_page_requires_reload %>
このヘルパーは次のような内容です。つまり、テンプレートのレイアウトに、 :head というコンテンツ挿入ポイントを期待しているようです。
Turbo は Turbo Frame リクエストを受けたときに、通常の application.html.erb を使わず、特別なレイアウトを使うように設定されています。それは、このとおり骨組みだけの内容ですが、コンテンツ挿入ポイントとして :head が設定されており、ヘルパーメソッドは問題なく役割を果たすことになります。むしろ、役割を果たすためにヘルパーを使うべき、とも言えるかもしれません。
対処法(2): Turbo の fetch 処理とイベントの確認
ログイン画面については、どこかのページの一部のフレームとして出すことは通常はないでしょうから、以上のように meta タグの埋め込みで十分に目的は達成できます。ただそれ以外の画面で、 meta タグを埋め込みたくない場合はどうでしょうか。そのようなケースを考えてみます。
まず、Turbo がフレームにレスポンスを差し込む処理は JavaScript 側で行われているため、フックポイントを探すことにしました。
Turbo には fetch 周りの処理に関するイベントがいくつか用意されています。中でも turbo:before-fetch-response は、fetch のレスポンスを受け取る直前に発火されるイベントで、そのインスタンスの detail にレスポンスの内容が含まれていることがわかりました。
これが得られるなら、リダイレクトされたことを検知できるのではないかと考え、試してみることにします。
リダイレクトを検出してフルページ遷移させる
turbo:before-fetch-response イベントを使って、リダイレクト・レスポンスであることを検知し、手動で window.top.location を書き換えるようにしてみました。
document.addEventListener("turbo:before-fetch-response", (event) => {
const response = event.detail.fetchResponse.response;
if (response.redirected) {
window.top.location = response.url;
}
});
このコードによって、リダイレクトが発生したときにページ全体を意図した URL に遷移させることができます。 Turbo によるフレームへの差し込みではなく、自分で遷移を制御する形になります。
プリフェッチでもリダイレクトされてしまう
ただしこのままだと、別の問題が起きました。 Turbo はリンクにマウスオーバーするだけで、自動的にそのリンク先に対して GET リクエストを送るプリフェッチを行います。
このプリフェッチに対しても turbo:before-fetch-response イベントは発火するため、まだクリックもしていない段階でリダイレクトされてしまうという、望ましくない動作になってしまいました。
プリフェッチかどうかを判別するにはどうしたら良いか、が次に解決すべきポイントになります。
プリフェッチかどうかを判別する
ログを出力しながら動作を確認してみると、プリフェッチと、実際にリンクをクリックした時のイベントのターゲット event.target の中身が異なることに気づきました。 event.target が、プリフェッチ時には <a> タグであるのに対し、実際にクリックして fetch が発生する場合には <turbo-frame> タグになっていたのです。
このことから、 event.target.tagName を使えばプリフェッチとクリックとを判別できるのではないかと考え、最終的には次のようなコードで、プリフェッチを無視しつつ、リダイレクト発生時にページ全体を書き換えることができるようになりました:
document.addEventListener("turbo:before-fetch-response", (event) => {
const response = event.detail.fetchResponse.response;
const tagName = event.target.tagName;
const isPrefetch = tagName.toLowerCase() === "a";
if (response.redirected && !isPrefetch) {
document.documentElement.style.visibility = "hidden";
window.top.location = response.url;
}
});
ここではページを置き換える直前にページ全体を見えなくなるようにしています。 Turbo はレスポンスを受け取るとすぐにフレームに挿入するため、 location によるページの書き換えがそれに間に合わず、一瞬だけ "Content missing" が見えてしまうことがあります。それを防ぐためのものです。
一見するとうまくいったように見えます。ただし event.target.tagName を用いたプリフェッチの判定はいまのところ有効ですが、本来の用途とは違いますし、 Turbo の内部構造に依存しているため、将来の変更で機能しなくなる可能性があることが、懸念材料として残ります。
まとめ
Turbo Frame はリンク先がリダイレクトすることもあることに注意を払わなければいけません。特に "Content missing" といった例外メッセージは、ユーザを混乱させるに十分なインパクトがあり、アプリ(サービス)の信頼性を疑わせてしまうかもしれません。
対策としては、ログイン画面といった独立したページであれば meta タグを置くことで対処するのが簡単です。
一方 meta タグを置けない場合は、イベントを利用することでリダイレクトを検知し、ページ全体を置き換えることはできました。ただしそれはワークアラウンド的な対処法と言わざるを得ないのが現状です。しかしそれを踏まえた上で限定的に使用すると割り切れるならば、そこから独自のロジックによって比較的自由度の高い対処ができそうです。
Discussion