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 1 commit 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 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 })
where $URL is set to the
redirect's path or URL, and $TURBO_FRAME is set to the turbo_frame:
argument.

  def create
    @article = Article.new article_params

    if @article.save
      redirect_to articles_url, turbo_frame: "_top"
    else
      render :new, status: :unprocessable_entity
    end
  end

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().

package.json Outdated Show resolved Hide resolved
@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
@davidalejandroaguilar
Copy link
Contributor

davidalejandroaguilar commented Apr 3, 2023

I think hotwired/turbo#863 shouldn't have been merged without merging this PR first. People were relying on the existing behavior to break out of frames.

Luckily there's an escape hatch. But it'd be great to have this so that we can remove it and control it ourselves more granularly.

It is especially useful when you want to conditionally break out from a frame, depending on arbitrary server state.

@jon-sully
Copy link

Having watched this PR and concept for a pretty long time, I wonder if this can actually be closed now. It seems like hotwired/turbo#863 and hotwired/turbo#867 together (with the escape hatch noted above and a couple other details to be aware of) should satisfy the needs for an app to 'break out' of a frame from either front-end controls or server responses.

@wasik
Copy link

wasik commented Apr 8, 2023

@jon-sully This seems to offer a lot more functionality than either hotwired/turbo#863 or hotwired/turbo#867. It is also a much cleaner and easier-to-use syntax, allowing "redirect_to" to actually perform a full-page redirect without having to add additional content on the target page (like a turbo-visit-control meta tag). As I understand it, it could also allow a frame response to target a different frame than the one that requested it, which has all sorts of really neat potential.

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
@feliperaul
Copy link
Contributor

@dhh Hoping to see this merged soon! 😃

@laptopmutia
Copy link

laptopmutia commented May 24, 2023

current work around for people that waiting this to be merged

this is how you could redirecting and rendering notice from a turbo frame

  • first add <%= yield :head %> to your layout
  • then on the redirect's page add this <% turbo_page_requires_reload %>
  • then on the controller of that redirected action you add this to render the notice flash.keep if turbo_frame_request?

@dhh
Copy link
Member

dhh commented Jun 20, 2023

I think there are too many moving parts in this setup at the moment. I think the underlying desire to be able to break out from the server side is right, but let's see if we can't find a path that uses the new turbo-visit-control setup for frames: hotwired/turbo#867.

@andrewhavens
Copy link

I'm really looking forward to being able to do: redirect_to url, turbo_frame: "_top". The current workaround using <meta name="turbo-visit-control" content="reload"> kind of works, but the problem is that it results in extra requests (two redirects) and a different behavior than simply specifying turbo_frame: "_top". In the case of redirecting to the index page after successfully creating a record, we don't actually need to do a full page reload. We just want to be able to render the response in the _top frame like every other case of using turbo_frame: "_top". So using turbo-visit-control does something different. First the redirect is performed, then when it finds the meta tag, it performs another request to the same URL to fetch the same response that we just received to simply to render the page, but it also, visually, looks like a full page load rather than swapping the body HTML like a normal Turbo visit.

@jon-sully
Copy link

but the problem is that it results in extra requests (two redirects)

This part is true out of the box, but FWIW we can hook into the JS event and traverse the response directly without another request. I outlined that here but the tl;dr: is

document.addEventListener("turbo:frame-missing", event => {
  event.preventDefault()
  event.detail.visit(event.detail.response)
})

And, while I think this is a common use-case (we needed this too), it's just one of many to consider when thinking through the back-end changes for supporting back-end-driven, explicit, frame navigations. In that sense, it's not like any other case of "turbo_frame: top". I'd be curious to see a solution that does provide some kind of helper to essentially automated the turbo-visit-control meta into the redirect_to 🤔

@dhh
Copy link
Member

dhh commented Jun 21, 2023

Please do explore both approaches. What I'm saying is that the implementation required in this PR is too heavy and cumbersome. I appreciate the effort, but let's try some alternate angles on the same problem. Feel free to CC me on additional attempts. We'll eventually crack this with a simple solution!

@kevinmcconnell
Copy link
Contributor

I think @jon-sully's idea would make a lot of sense here, of using a helper to bring the information across from the redirect_to.

The intention of hotwired/turbo#867 was to give an easy escape hatch for pages that should never be in a frame, but which should instead always be full-page loads. That's a common enough situation when dealing with things like login pages that it needs an easy solution, and we wanted a way to state that on the page itself rather than have to accomodate it in the controllers. It should feel like configuration. That, and the fact that we weren't respecting turbo-visit-control for frame requests before was arguably a bug.

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 think that will be more straightforward than the method proposed here. And also would make it available to people using Turbo outside of Rails -- we can include a helper here to make it easy to include that header from the redirect_to call, but on other platforms people could set the header however they want.

Also, as an aside -- 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. Streams already provides a way to target arbitrary areas of the page, so if you just need to have one frame update another, you can already do that with Streams. 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. (Which is not to say that Streams is perfect either, of course. But just that if we need more flexibility in triggering updates outside of the calling frame, I think it's the right place to start).

@andrewhavens
Copy link

andrewhavens commented Jun 21, 2023

After reading through the source of this PR and thinking more about the problem...I think the solution to this is simpler than I realized. I think the idea behind this PR is correct (respond with some javascript to perform a Turbo.visit rather than using turbo-visit-control since they are semantically different things).

For those who need a short-term fix for this problem, here is a helper method you can add to your ApplicationController:

def turbo_visit(url, frame: nil, action: nil)
  options = {frame: frame, action: action}.compact
  turbo_stream.append_all("head") do
    helpers.javascript_tag(<<~SCRIPT.strip, nonce: true, data: {turbo_cache: false})
      window.Turbo.visit("#{helpers.escape_javascript(url)}", #{options.to_json})
      document.currentScript.remove()
    SCRIPT
  end
end

# then you use it like this:
def create
  @article = Article.new article_params

  if @article.save
    render turbo_stream: turbo_visit(articles_url)
  else
    render :new, status: :unprocessable_entity
  end
end

As far as overriding redirect_to so we could do redirect_to url, turbo_frame: "_top", notice: "message", you could do something simple like this:

def redirect_to(url_options = {}, response_options = {})
  turbo_frame = response_options.delete(:turbo_frame)
  turbo_action = response_options.delete(:turbo_action)
  return super unless request.format.turbo_stream? && turbo_frame.present?

  location = url_for(url_options)
  response_options.slice(:flash, :notice, :alert).each do |key, value|
    (key == :flash) ? flash.update(value) : flash[key] = value
  end
  render turbo_stream: turbo_visit(location, frame: turbo_frame, action: turbo_action)
end

But we would probably need something more robust, like this (which copies some stuff from this PR and the current implementation of redirect_to):

def redirect_to(options = {}, response_options = {})
  turbo_frame = response_options.delete(:turbo_frame)
  turbo_action = response_options.delete(:turbo_action)
  return super unless request.format.turbo_stream? && turbo_frame.present?

  allow_other_host = response_options.delete(:allow_other_host) { _allow_other_host }
  location = _enforce_open_redirect_protection(_compute_redirect_to_location(request, options), allow_other_host: allow_other_host)

  self.class._flash_types.each do |flash_type|
    if type = response_options.delete(flash_type)
      flash[flash_type] = type
    end
  end

  if other_flashes = response_options.delete(:flash)
    flash.update(other_flashes)
  end

  render turbo_stream: turbo_visit(location, frame: turbo_frame, action: turbo_action)
end

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
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