turbo_page_requires_reload を使って TurboFrame の中からリダイレクトしてみた

## はじめに

この記事は [How to break out of a Turbo Frame and redirect?](https://www.ducktypelabs.com/turbo-break-out-and-redirect/) で紹介されている1つ目の解決策 `turbo_page_requires_reload` について調べた記録です。

結論として、私は以下に記載する方法より Turbo.visit を利用して TurboStream カスタムアクションを追加する方法をおすすめします。こちらの方法も上記リンク先の記事で紹介されています。

## 課題

TurboFrame 内にあるフォームから POST された後、リダイレクトしたくても思うように動作しないことがあります。これはリダイレクトの GET リクエストにも TurboFrame Target の制約があるためです。

避けられればよいのですが、TurboFrame の中からのリクエストに対しページ全体をリフレッシュしたりページ遷移させたいことは、実際ときどきおこります。form タグに `data-turbo-frame="_top"` をつけられればよいのですが、バリデーションエラーを表示したいときはこれができません。

## 解決策（の1つ）

リダイレクト先のページに `turbo_page_requires_reload` を追加し、再び GET (HTML) させることで redirect_to に指定したページを表示させることができます。

### turbo_page_requires_reload とは？

turbo-rails に用意されているヘルパーメソッドの1つです。
https://github.com/hotwired/turbo-rails/blob/0e42702df40698521cc7e506fea927bc3a2a08ab/app/helpers/turbo/drive_helper.rb#L42-L44

```html
<meta name="turbo-visit-control" content="reload">
```

を `<head>` に埋め込みます。

この turbo-visit-control reload の meta タグは turbo-frame からリクエストされたときにページリロードする効果があります。
https://turbo.hotwired.dev/reference/attributes#meta-tags

（注意）head タグの中に `<%= yield :head %>` がなければ meta タグを埋め込むヘルパーメソッドが効かないので注意してください。この yield は turbo_frame/turbo_stream のレイアウトファイルや、Rails 8.0 の `layouts/application.html.erb` には標準で用意されています。

### サンプルコード（不具合あり）

https://github.com/morikiyo/sandbox-20241202

<details><summary>model</summary>

```ruby:app/models/memo.rb
class Memo < ApplicationRecord
  # body:string だけの簡単なモデルです
  validates :body, presence: true
end
```
</details>

<details><summary>controller</summary>

```ruby:app/controllers/memos_controller.rb
class MemosController < ApplicationController
  before_action :set_memo, only: %i[ show edit update destroy ]

  def index
    @memos = Memo.order(id: :desc)
  end

  def new
    @memo = Memo.new
  end

  def create
    @memo = Memo.new(memo_params)
    if @memo.save
      redirect_to memos_path, status: :see_other, notice: "Memo was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  # (他のアクションは割愛)

  private

    # ...

    def memo_params
      params.expect(memo: [ :body ])
    end
end
```
</details>

<details><summary>view</summary>

```erb:app/views/memos/index.html.erb
<div class="row mb-5">
  <div class="col-12 col-md-6">
    <%= turbo_frame_tag Memo.new, src: new_memo_path %>
  </div>
</div>

<h2>Memos</h2>

<div class="row">
  <table class="col-12 col-md-6 table table-striped">
    <thead>
      <tr>
        <th>ID</th>
        <th>Memo</th>
        <th></th>
      </tr>
    </thead>
    <tbody>
      <% @memos.each do |memo| %>
        <tr>
          <td><%= memo.id %></td>
          <td>
            <%= turbo_frame_tag memo, src: memo_path(memo), loading: :lazy %>
          <td>
            <%= button_to "Destroy", memo, method: :delete, class: "btn btn-sm btn-danger", data: { turbo_confirm: "Are you sure?" } %>
          </td>
        </tr>
      <% end %>
    </tbody>
  </table>
</div>
```

```erb:app/views/memos/new.html.erb
<%= turbo_frame_tag @memo do %>
  <h2>New memo</h2>
  <%= render "form", memo: @memo %>
<% end %>
```

```erb:app/views/memos/_form.html.erb
<%= form_with(model: memo) do |form| %>
  <% #... %>

  <div class="mb-3">
    <%= form.text_field :body, class: "form-control" %>
  </div>

  <div class="d-flex gap-3 align-items-center">
    <%= form.submit class: "btn btn-primary" %>
    <% if memo.persisted? %>
      <%= link_to "Cancel", memo_path(memo) %>
    <% end %>
  </div>
<% end %>
```
</details>

これは、このままだと create アクションが正しく動作しません。

リクエスト 1回目 POST
リクエスト 2回目 GET (303 redirect + TurboFrame/TurboStream) 

となりますが、TurboFrame のみを書き換えようとし、結果、TurboFrame の中身が消えてしまいます。

### 修正コード

https://github.com/morikiyo/sandbox-20241202/pull/1

```erb:app/views/memos/index.html.erb
<% turbo_page_requires_reload %>
<% #... %>
```

`turbo_page_requires_reload` を index view に追加し、`<meta name="turbo-visit-control" content="reload">` を head に差し込みます。

そうすると

リクエスト 1回目 POST 
リクエスト 2回目 GET (303 redirect + TurboFrame/TurboStream) 
リクエスト 3回目 GET (HTML) ... turbo-visit-control により reload される

となり、index ページ全体がリフレッシュされます。

ただし、flash メッセージが redirect による GET で消えてしまうため、`flash.keep` をしておく必要があります。

```ruby:app/controllers/memos_controller.rb
def index
  flash.keep if turbo_frame_request?
  # ...
end
```

（疑問）以下のソースをみると before_action に同様のコードがあるため、追加する必要はないように思えるが、実際、処理を追加しないと動かなかった
https://github.com/hotwired/turbo-rails/blob/0e42702df40698521cc7e506fea927bc3a2a08ab/app/controllers/turbo/frames/frame_request.rb#L26

## おわりに（感想など）

上記の方法はすごく少ない修正で解決できるのがメリットですが、同じデータを2回 GET しており、表示するコストが大きいページでこの方法を選択するのは避けたいです。

「はじめに」で書いたように Turbo.visit を実行する TurboStream のカスタムアクションを用意するのが汎用的でよい方法だと思いますし、同じページをリフレッシュするなら [turbo_stream.refresh](https://turbo.hotwired.dev/handbook/page_refreshes#broadcasting-page-refreshes) という選択肢もあります。

```ruby
  flash.notice = "Memo was successfully created."
  render turbo_stream: turbo_stream.refresh(request_id: nil)
```

※ `request_id: nil` についてはこちらの [issue](https://github.com/hotwired/turbo/issues/1173) を参照

データ登録後に「完了しました」ページや「これ以上は利用できません」などのエラーページに遷移させたいくらいの用途でしたらマッチするかもしれませんが、Turbo.visit できるカスタムアクションがあればこの方法は選択することはないと思います。

今回は turbo_page_requires_reload という機能がある、ということだけ頭の片隅にしまっておきます。


## 告知

**Hotwire.love** という勉強会を毎月第3 or 第4木曜に開催しています。この記事も Hotwire.love の中でとりあげたものを調べてまとめてみました。hotwire に興味のあるかたは是非遊びに来てください。

https://hotwire-love.connpass.com/