Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"Break out" of a frame from the server #367

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

seanpdoyle
Copy link
Contributor

@seanpdoyle seanpdoyle commented Jul 31, 2022

Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

Introduces the Turbo::Stream::Redirect concern to introduce the
#break_out_of_turbo_frame_and_redirect_to and
#turbo_stream_redirect_to methods. The
#break_out_of_turbo_frame_and_redirect_to draws inspiration from the
methods provided by the Turbo::Native::Navigation concern.

When handling requests made from outside a <turbo-frame> elements
(without the Turbo-Frame HTTP header), respond with a typical HTML
redirect response.

When handling request made from inside a <turbo-frame> element (with
the Turbo-Frame HTTP header), render a <turbo-stream action="visit">
element with the redirect's pathname or URL encoded into the
[location] attribute.

When Turbo Drive receives the response, it will call Turbo.visit() with
the value read from the [location] attribute.

class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
  end

  def create
    @article = Article.new(article_params)

    if @article.save
      break_out_of_turbo_frame_and_redirect_to @article
    else
      render :new, status: :unprocessable_entity
    end
  end
end

Response options (like :notice, :alert, :status, etc.) are
forwarded to the underlying redirection mechanism (#redirect_to for
Mime[:html] requests and #turbo_stream_redirect_to for
Mime[:turbo_stream] requests).

This enables server-side actions to navigate the entire page with a,
regardless of the provenance of the request.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the Turbo.visit
call.

To support this behavior, this commit introduces the first
@hotwire/turbo-rails-specific Turbo StreamAction: the
turbo-stream[action="visit"].

package.json Outdated Show resolved Hide resolved
@seanpdoyle seanpdoyle force-pushed the turbo-frame-breakout branch from 3f66023 to d8e85d6 Compare July 31, 2022 23:56
@seanpdoyle
Copy link
Contributor Author

The suite passes locally when executing bin/test and bin/test test/system/*_test.rb. I'm not sure why failures are so common in CI.

@maxwell
Copy link

maxwell commented Aug 5, 2022

I am not responsible for Devise, but wanted to give this a big thumbs up, as imho this PR would help Devise support Turbo a bit better.

There is a pattern that I feel a lot of apps use which is to have some sort of <a> tag that routes to a resource behind authenticate_user, with the expectation would be you log in, and then takes you back.

If that link is currently inside a turbo_frame, that behavior just hides the turbo frame, and it's just generally unclear what the expectation of reasonable default behavior should be in this scenario.

WIth this PR (and maybe an updated PR in responders/devise), forcing the turbo_frame: '_top' makes the user behavior closely match our pre-turbo world.

@seanpdoyle seanpdoyle force-pushed the turbo-frame-breakout branch 2 times, most recently from 6ab111c to 29e1101 Compare August 8, 2022 18:24
@seanpdoyle seanpdoyle marked this pull request as ready for review August 8, 2022 22:27
@seanpdoyle seanpdoyle force-pushed the turbo-frame-breakout branch 2 times, most recently from 1d98a5d to d0596ce Compare August 8, 2022 23:13
@tomasc
Copy link

tomasc commented Aug 19, 2022

@seanpdoyle this is very helpful, and much needed. Thank you!
I tested your branch in my app, seemed to work fine. The only thing I noticed is that the turbo-action stopped working – the URL did not update anymore when clicking on a link with turbo-action='advance'.

@seanpdoyle
Copy link
Contributor Author

@tomasc thank you for raising that concern! I've opened hotwired/turbo#694 to make the Turbo Action available to the server as a request header. If that lands, this implementation could incorporate that value into the Stream's Turbo.visit call.

@tomasc
Copy link

tomasc commented Aug 19, 2022

Thanks @seanpdoyle ! Hope it gets merged soon, I could use this on a project right now ;-).

@seanpdoyle seanpdoyle force-pushed the turbo-frame-breakout branch 3 times, most recently from cbe2e96 to 8d8b3f2 Compare September 19, 2022 13:54
robzolkos added a commit to robzolkos/modal-hotwire-example that referenced this pull request Feb 8, 2024
@robzolkos
Copy link

Wondering what the status is on this one? I tried it in a test project and it worked wonderfully and I haven't seen anything else as elegant as adding turbo_frame: _top to a standard rails redirect.

@yshmarov
Copy link

yshmarov commented Feb 8, 2024

looks great, looking forward to it being merged!

My current solution:

// app/javascript/application.js
Turbo.StreamActions.redirect = function () {
  Turbo.visit(this.target);
};
# my_controller.rb
      render turbo_stream: turbo_stream.action(:redirect, posts_path)

@dwaynemac
Copy link

@yshmarov's solution looks super clean!

@gap777
Copy link

gap777 commented Feb 9, 2024

@yshmarov Have you thought of a way to combine your solution to also have an alert/notice?

@yshmarov
Copy link

yshmarov commented Feb 9, 2024

it works with flash, just like any other page redirect.
here's an example of opening a turbo_frame modal, creating a record, and the turbo_stream.action(:redirect performs a full page redirect with breaking out of the frame
flash is ok

@gap777
Copy link

gap777 commented Feb 9, 2024

@yshmarov That's pretty nice.

@davidalejandroaguilar
Copy link
Contributor

@yshmarov If you want to keep scroll, add replace to your visit:

Turbo.visit(url, { action: "replace" })

@seanpdoyle
Copy link
Contributor Author

seanpdoyle commented Mar 15, 2024

@kevinmcconnell in response to #367 (comment), I've tried my best to distill a common use case I've encountered into a self-contained application.

Boiled down, the scenario entails the following:

  1. a page serving as an entry point (in this case, todos#index)
  2. a <turbo-frame> within that page linking to a form page (in this case, todos#new)
  • contains both a <form> and a link to navigate the <turbo-frame> "back"
  1. clicking the "new" link should navigate the <turbo-frame> to present the form
  2. clicking the "cancel" link" should navigate the <turbo-frame> "back" without affecting content anywhere else on the page
  3. invalid submissions of the <form> within the <turbo-frame> should re-render the form inside the <turbo-frame> without affecting content anywhere else on the page
  4. valid submissions of the <form> within the <turbo-frame> should "break out" of the frame and navigate the entire page

I've created a single-file application to make this scenario more concrete. You can copy-paste the following into a app.rb file, then execute it with ruby app.rb.

It utilizes the <meta name="turbo-visit-control" content="reload"> technique added to Turbo in hotwired/turbo#867. It determines when to render the element based on whether or not the request is being made from a frame (through the Turbo-Frame: HTTP header), along with the presence of a ?turbo_visit_control=reload query parameter set whenever the todos#create action succeeds in creating a Todo.

It "works", in that it meets the acceptance criteria outlined above without introducing new abstractions or Turbo mechanisms. However, the resulting Turbo.visit-driven navigation retains the ?turbo_visit_controler=reload query parameter as part of the final URL. I dislike the fact that this work-around leaks that sort of implementation detail.

There are three System Tests covering the behavior. If you'd like to experiment with it locally, change the headless: false option to headless: true, then insert a binding.irb after one of the visit root_path calls.

But if there is a need to conditionally turn a frame request into a full-page navigation, we could consider adding a frame header to the redirect response to indicate that. On the Turbo side, when a turbo-frame receives a redirect with that header, it can follow it using a visit rather than fetching it into the frame.

I've explored this, and haven't found a way to make a server-set HTTP header available to Turbo when the resulting redirect occurs. I believe Turbo's use of fetch makes the intermediate HTTP response inaccessible. Have you had success in achieving that behavior?

for anything more involved than turning a frame request into navigation, I think it's worth leaning on the existing abilities of Turbo Streams rather than making Turbo Frames more flexible with which parts of the page it updates.
...
Making Frames any more "server-directed" would mean more overlap between Frames and Streams, and I think it will start to introduce complexity that we don't need.

Is there an architectural change to be made to Turbo to improve support for this style of scenario? If there isn't a way to support this directly, are there any examples of concrete changes to the example application below that you'd make to flesh out Turbo Stream-powered solutions?

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails"
  gem "propshaft"
  gem "puma"
  gem "sqlite3"
  gem "turbo-rails"

  gem "capybara"
  gem "cuprite", "~> 0.9", require: "capybara/cuprite"
end

ENV["DATABASE_URL"] = "sqlite3::memory:"
ENV["RAILS_ENV"] = "test"

require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "rails/test_unit/railtie"

class App < Rails::Application
  config.load_defaults Rails::VERSION::STRING.to_f

  config.root = __dir__
  config.eager_load = false
  config.secret_key_base = "secret_key_base"
  config.consider_all_requests_local = true
  config.turbo.draw_routes = false

  routes.append do
    resources :todos, only: [:new, :create]
    root to: "todos#index"
  end
end

Rails.application.initialize!

ActiveRecord::Schema.define do
  create_table :todos, force: true do |t|
    t.text :body, null: false
  end
end

class Todo < ActiveRecord::Base
  validates :body, presence: true
end

class TodosController < ActionController::Base
  include Rails.application.routes.url_helpers

  class_attribute :template, default: DATA.read

  def index
    @todos = Todo.all

    render inline: template, formats: :html
  end

  def new
    @todo = Todo.new

    render_new_template
  end

  def create
    @todo = Todo.new(params.require(:todo).permit(:body))

    if @todo.save
      flash.notice = "Todo created"
      redirect_to root_url(turbo_visit_control: "reload")
    else
      render_new_template status: :unprocessable_entity
    end
  end

  helper_method def breaking_out_of_turbo_frame?
    turbo_frame_request? && params[:turbo_visit_control] == "reload"
  end
  before_action -> { flash.keep }, if: :breaking_out_of_turbo_frame?

  private

  def render_new_template(**)
    render formats: :html, inline: <<~HTML, **
      <turbo-frame id="new_todo">
        <%= "Failed to create Todo" if @todo.errors.any? %>

        <%= form_with model: @todo do |form| %>
          <%= form.label :body %>
          <%= form.text_field :body %>
          <%= form.button %>
        <% end %>

        <%= link_to "Cancel", root_path %>
      </turbo-frame>
    HTML
  end
end

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :cuprite, using: :chrome, screen_size: [1400, 1400], options: {js_errors: true, headless: true}
end

require "rails/test_help"

class TurboSystemTest < ApplicationSystemTestCase
  test "navigates frame to form and back" do
    visit root_path
    fill_in "Search", with: "a search term"
    click_link "New Todo"

    assert_field "Search", with: "a search term"
    assert_field "Body"

    click_link "Cancel"

    assert_field "Search", with: "a search term"
    assert_no_field "Body"
    assert_no_text "Todo created"
  end

  test "re-renders the form with validation messages within the frame" do
    visit root_path
    fill_in "Search", with: "a search term"
    click_link "New Todo"
    fill_in "Body", with: "    "
    click_button "Create Todo"

    assert_field "Body", with: "    "
    assert_text "Failed to create Todo"
    assert_no_text "Todo created"
    assert_no_css "p"
  end

  test "breaks out of frame on successful creation" do
    visit root_path
    fill_in "Search", with: "a search term"
    click_link "New Todo"
    fill_in "Body", with: "Hello, world"
    click_button "Create Todo"

    assert_css "p", text: "Hello, world"
    assert_text "Todo created"
    assert_field "Search", with: ""
    assert_no_field "Body"
  end
end

__END__

<!DOCTYPE html>
<html>
  <head>
    <%= csrf_meta_tags %>

    <script type="importmap">
      {
        "imports": {
          "@hotwired/turbo-rails": "<%= asset_path("turbo.js") %>"
        }
      }
    </script>

    <script type="module">
      import "@hotwired/turbo-rails"
    </script>

    <%= turbo_page_requires_reload_tag if breaking_out_of_turbo_frame? %>
  </head>

  <body>
    <%= flash.notice if flash.notice.present? %>
    <label>Search to demonstrate page state being preserved <input></label>

    <turbo-frame id="new_todo">
      <%= link_to "New Todo", new_todo_path %>
    </turbo-frame>

    <% @todos.each do |todo| %>
      <p><%= todo.body %></p>
    <% end %>
  </body>
</html>

@seanpdoyle
Copy link
Contributor Author

Incorporating @yshmarov suggestion from (#367 (comment)) into the requires the following changes shared below.

diff --git a/app.rb b/app.rb
index aad947f..6dbadfb 100644
--- a/app.rb
+++ b/app.rb
@@ -71,17 +71,17 @@ class TodosController < ActionController::Base
     @todo = Todo.new(params.require(:todo).permit(:body))
 
     if @todo.save
       flash.notice = "Todo created"
-      redirect_to root_url(turbo_visit_control: "reload")
+      respond_to do |format|
+        format.html { redirect_to root_url }
+        format.turbo_stream { render turbo_stream: turbo_stream.action(:visit, root_url) }
+      end
     else
       render_new_template status: :unprocessable_entity
     end
   end
 
-  helper_method def breaking_out_of_turbo_frame?
-    turbo_frame_request? && params[:turbo_visit_control] == "reload"
-  end
-  before_action -> { flash.keep }, if: :breaking_out_of_turbo_frame?
-
   private
 
   def render_new_template(**)
@@ -166,10 +166,11 @@ __END__
     </script>
 
     <script type="module">
-      import "@hotwired/turbo-rails"
+      import { Turbo } from "@hotwired/turbo-rails"
+      Turbo.StreamActions.visit = function () {
+        Turbo.visit(this.target)
+      }
     </script>
-
-    <%= turbo_page_requires_reload_tag if breaking_out_of_turbo_frame? %>
   </head>
 
   <body>

While it's a suitable workaround given the constraints, and behaves the way it needs to, I dislike that it mixes HTML and Turbo Stream content types. Through that lens, I'm similarly dissatisfied with the original approach proposed by this PR's changeset.

What I've come to appreciate about the redirect_to-powered Page Refresh is that the client-server communication revolves entirely around HTTP and text/html. Like @kevinmcconnell mentioned in #367 (comment), adding abstractions to turbo-rails to improve the ergonomics around this type of interaction would need to be replicated in other server contexts.

Copy-paste the following into a app.rb file, then execute it with ruby app.rb.
require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails"
  gem "propshaft"
  gem "puma"
  gem "sqlite3"
  gem "turbo-rails"

  gem "capybara"
  gem "cuprite", "~> 0.9", require: "capybara/cuprite"
end

ENV["DATABASE_URL"] = "sqlite3::memory:"
ENV["RAILS_ENV"] = "test"

require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "rails/test_unit/railtie"

class App < Rails::Application
  config.load_defaults Rails::VERSION::STRING.to_f

  config.root = __dir__
  config.eager_load = false
  config.secret_key_base = "secret_key_base"
  config.consider_all_requests_local = true
  config.turbo.draw_routes = false

  routes.append do
    resources :todos, only: [:new, :create]
    root to: "todos#index"
  end
end

Rails.application.initialize!

ActiveRecord::Schema.define do
  create_table :todos, force: true do |t|
    t.text :body, null: false
  end
end

class Todo < ActiveRecord::Base
  validates :body, presence: true
end

class TodosController < ActionController::Base
  include Rails.application.routes.url_helpers

  class_attribute :template, default: DATA.read

  def index
    @todos = Todo.all

    render inline: template, formats: :html
  end

  def new
    @todo = Todo.new

    render_new_template
  end

  def create
    @todo = Todo.new(params.require(:todo).permit(:body))

    if @todo.save
      flash.notice = "Todo created"

      respond_to do |format|
        format.html { redirect_to root_url }
        format.turbo_stream { render turbo_stream: turbo_stream.action(:visit, root_url) }
      end
    else
      render_new_template status: :unprocessable_entity
    end
  end

  private

  def render_new_template(**)
    render formats: :html, inline: <<~HTML, **
      <turbo-frame id="new_todo">
        <%= "Failed to create Todo" if @todo.errors.any? %>

        <%= form_with model: @todo do |form| %>
          <%= form.label :body %>
          <%= form.text_field :body %>
          <%= form.button %>
        <% end %>

        <%= link_to "Cancel", root_path %>
      </turbo-frame>
    HTML
  end
end

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :cuprite, using: :chrome, screen_size: [1400, 1400], options: {js_errors: true, headless: true}
end

require "rails/test_help"

class TurboSystemTest < ApplicationSystemTestCase
  test "navigates frame to form and back" do
    visit root_path
    fill_in "Search", with: "a search term"
    click_link "New Todo"

    assert_field "Search", with: "a search term"
    assert_field "Body"

    click_link "Cancel"

    assert_field "Search", with: "a search term"
    assert_no_field "Body"
    assert_no_text "Todo created"
  end

  test "re-renders the form with validation messages within the frame" do
    visit root_path
    fill_in "Search", with: "a search term"
    click_link "New Todo"
    fill_in "Body", with: "    "
    click_button "Create Todo"

    assert_field "Body", with: "    "
    assert_text "Failed to create Todo"
    assert_no_text "Todo created"
    assert_no_css "p"
  end

  test "breaks out of frame on successful creation" do
    visit root_path
    fill_in "Search", with: "a search term"
    click_link "New Todo"
    fill_in "Body", with: "Hello, world"
    click_button "Create Todo"

    assert_css "p", text: "Hello, world"
    assert_text "Todo created"
    assert_field "Search", with: ""
    assert_no_field "Body"
  end
end

__END__

<!DOCTYPE html>
<html>
  <head>
    <%= csrf_meta_tags %>

    <script type="importmap">
      {
        "imports": {
          "@hotwired/turbo-rails": "<%= asset_path("turbo.js") %>"
        }
      }
    </script>

    <script type="module">
      import { Turbo } from "@hotwired/turbo-rails"
      Turbo.StreamActions.visit = function () {
        Turbo.visit(this.target)
      }
    </script>
  </head>

  <body>
    <%= flash.notice if flash.notice.present? %>
    <label>Search to demonstrate page state being preserved <input></label>

    <turbo-frame id="new_todo">
      <%= link_to "New Todo", new_todo_path %>
    </turbo-frame>

    <% @todos.each do |todo| %>
      <p><%= todo.body %></p>
    <% end %>
  </body>
</html>

@seanpdoyle
Copy link
Contributor Author

There are two semantically meaningful HTTP status codes that might be worth considering as special-case escape hatches to "break out" of Turbo Frame requests from the server.

There is 201 Created:

indicates that the request has succeeded and has led to the creation of a resource. The new resource, or a description and link to the new resource, is effectively created before the response is sent back and the newly created items are returned in the body of the message, located at either the URL of the request, or at the URL in the value of the Location header.

The common use case of this status code is as the result of a POST request.

This means that a server like Rails could control a frame with a status code through something like head:

def create
  @todo = Todo.new(todo_params)
  
  if @todo.save
    if turbo_frame_request?
      head :created, location: @todo
    else
      redirect_to @todo
    end
  else
    render :new, status: :unprocessable_entity
  end
end

Then the Turbo Frame Controller could special case responses with 201 Created, then call Turbo.visit(response.headers["Location"]).

There is also 205 Reset Content:

tells the client to reset the document view, so for example to clear the content of a form, reset a canvas state, or to refresh the UI.

This could communicate to Turbo that it should "reset" the view, similar to a Page Refresh. The downside here is that it doesn't have the Location: header, so the response couldn't semantically encode any information to indicate where to navigate to, other than window.location.href.

@doabit
Copy link

doabit commented Apr 15, 2024

@yshmarov Perfect, thanks.

rossta added a commit to joyofrails/joyofrails.com that referenced this pull request Jul 24, 2024
Now UX is to click a "New post" button that will render the post form
dynamically in a target turbo frame. The Stimulus refresh controller is
renamed as it now needs to handle a special redirect use case; on
successful submission of the POST from the "examples_post_form" we want
to render an updated list in the "examples_posts" turbo frame. The
framework does not yet provide an intuitive mechanism for breaking out
of a frame on redirect, so we use a Stimulus Turbo.visit to the
"examples_post" form here. This does result in a "double GET" request,
the first results in re-rendering the requesting "examples_post_form",
but the second is needed to re-render the "examples_posts" frame.

There are other approaches, described in the resources below, which also
lay out the problem in more detail:

- https://www.ducktypelabs.com/turbo-break-out-and-redirect/
- hotwired/turbo-rails#367 (comment)
- https://discuss.hotwired.dev/t/break-out-of-a-frame-during-form-redirect/1562/26
@aaronbrethorst
Copy link

I'd love to see this get merged. I've used it so thoroughly and comprehensively in one project that I was thrown for a loop when I discovered in a new Rails 8 project that this didn't work. It was only after I reviewed every instance of "turbo_frame" in my codebase did I remember that I had this file in my codebase:

# TODO: Remove after https://github.com/hotwired/turbo-rails/pull/367 is merged.
module Turbo::Streams::Redirect
  extend ActiveSupport::Concern

  def redirect_to(options = {}, response_options = {})
    turbo_frame = response_options.delete(:turbo_frame)
    location = url_for(options)

    if request.format.turbo_stream? && turbo_frame.present?
      alert, notice, flash_override = response_options.values_at(:alert, :notice, :flash)
      flash.merge!(flash_override || { alert:, notice: })

      case Rack::Utils.status_code(response_options.fetch(:status, :created))
      when 300..399 then response_options[:status] = :created
      end

      render "turbo/streams/redirect", **response_options.with_defaults(
        locals: { location:, turbo_frame: },
        location:
      )
    else
      super
    end
  end
end

@master-of-null
Copy link

Really hoping we can get this merged. Been wanting this functionality and resorting to client side forced refreshes.

Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][], and marking
it with [data-turbo-cache="false"][]

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
[data-turbo-cache="false"]: https://turbo.hotwired.dev/reference/attributes#data-attributes
@seanpdoyle
Copy link
Contributor Author

I've re-purposed this PR to pitch a different approach. I've updated the PR description to elaborate on the details.

@gap777
Copy link

gap777 commented Nov 23, 2024

Looks great!

@seanpdoyle seanpdoyle force-pushed the turbo-frame-breakout branch 2 times, most recently from 71b5dd1 to aa39599 Compare November 23, 2024 22:44
…_and_redirect_to`

Introduces the `Turbo::Stream::Redirect` concern to introduce the
`#break_out_of_turbo_frame_and_redirect_to` and
`#turbo_stream_redirect_to` methods. The
`#break_out_of_turbo_frame_and_redirect_to` draws inspiration from the
methods provided by the [Turbo::Native::Navigation][] concern.

When handling requests made from outside a `<turbo-frame>` elements
(without the `Turbo-Frame` HTTP header), respond with a typical HTML
redirect response.

When handling request made from inside a `<turbo-frame>` element (with
the `Turbo-Frame` HTTP header), render a `<turbo-stream action="visit">`
element with the redirect's pathname or URL encoded into the
`[location]` attribute.

When Turbo Drive receives the response, it will call `Turbo.visit()` with
the value read from the `[location]` attribute.

```ruby
class ArticlesController < ApplicationController
  def show
    @Article = Article.find(params[:id])
  end

  def create
    @Article = Article.new(article_params)

    if @article.save
      break_out_of_turbo_frame_and_redirect_to @Article
    else
      render :new, status: :unprocessable_entity
    end
  end
end
```

Response options (like `:notice`, `:alert`, `:status`, etc.) are
forwarded to the underlying redirection mechanism (`#redirect_to` for
`Mime[:html]` requests and `#turbo_stream_redirect_to` for
`Mime[:turbo_stream]` requests).

This enables server-side actions to navigate the entire page with a,
regardless of the provenance of the request.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

To support this behavior, this commit introduces the first
`@hotwire/turbo-rails`-specific Turbo `StreamAction`: the
`turbo-stream[action="visit"]`.

[Turbo::Native::Navigation]: https://github.com/hotwired/turbo-rails/blob/v2.0.11/app/controllers/turbo/native/navigation.rb
@davidalejandroaguilar
Copy link
Contributor

davidalejandroaguilar commented Nov 24, 2024

I'd like to share some concerns about the API changes, since they feel like a step backward.

The previous API was simple and intuitive:

redirect_to articles_url, turbo_frame: "_top"

Now we have break_out_of_turbo_frame_and_redirect_to which feels like it's trading the elegant simplicity Rails is known for in favor of explicit verbosity.

I know this change came from @kevinmcconnell's comment about using Turbo Streams rather than making Turbo Frames more flexible with page updates. The reasoning being that Streams already provides ways to target arbitrary areas of the page, and making Frames more "server-directed" could create unnecessary overlap.

However, I see two key reasons to reconsider this:

  1. The previous approach actually maintained a clean separation:
  • Streams would still be the go-to for DOM manipulation (both local and for other clients).
  • Frame redirects would stick to navigation and frame targeting - they're about where to navigate, not how to update the DOM.
  • No functional overlap between these responsibilities.
  1. The developer experience was significantly better:
  • redirect_to some_url, turbo_frame: "some_frame" gave us a consistent API for targeting any frame from the server - whether breaking out with "_top" or targeting specific frames like we do in our views.
  • This simple pattern opens up elegant frame navigation possibilities without adding complexity.
  • Using Streams for this would force custom Stream actions for what used to be a simple one-liner.

Could we reconsider the previous API? I think it'd maintain a consistent pattern while keeping Frames and Streams focused on their core purposes.

@cpgo
Copy link

cpgo commented Dec 13, 2024

But if there is a need to conditionally turn a frame request into a full-page navigation, we could consider adding a frame header to the redirect response to indicate that. On the Turbo side, when a turbo-frame receives a redirect with that header, it can follow it using a visit rather than fetching it into the frame.

I really like that idea, it sounds very simple and is easy to implement on non rails backends

@seanpdoyle
Copy link
Contributor Author

But if there is a need to conditionally turn a frame request into a full-page navigation, we could consider adding a frame header to the redirect response to indicate that. On the Turbo side, when a turbo-frame receives a redirect with that header, it can follow it using a visit rather than fetching it into the frame.

I really like that idea, it sounds very simple and is easy to implement on non rails backends

I have explored that possibility in the past, but could not find a way to make it work with fetch. If you explore it on your own and are able to make progress, please share!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Ability to override frame-target from server response