hotwired / turbo

The speed of a single-page web application without having to write any JavaScript
https://turbo.hotwired.dev
MIT License
6.75k stars 430 forks source link

Ability to override frame-target from server response #257

Open acetinick opened 3 years ago

acetinick commented 3 years ago

Currently if the response from turbo requests returns a redirect response whilst inside a frame, there is no way for the server side to conditionally control what turbo to do with the response, eg. force a full page refresh with target="_top"

There are use cases especailly for modal windows where upon succesful save we would want to perform a redirect back to the index page, where as 422 response would do the normal turbo frame replacement within current frame.

It would be nice to support something like this, which will allow us to control targets from serverside to frame targets by setting frame target via the http response headers.

 if @record.save
  response.headers["Turbo-Frame"] = "_top"
  redirect_to settings_locations_url
else
  render partial: 'form', status: :unprocessable_entity
end

What everyones thoughts are on this? or is there something I am missing to make this easier.

elik-ru commented 1 year ago

@lukepass But it looks like you can restore this functionality by adding a custom event handler to application.js:

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

event.target contains "turbo-frame#modal_inner", so you can check it if needed.

chmich commented 1 year ago

In the meantime, I wrote the render_turbo_stream gem. There are some helpers that make it easier to control the turbo actions in general.

lukepass commented 1 year ago

Understood @elik-ru thanks! @chmich unfortunately I'm using Turbo along with Symfont UX but I can work on elik-ru's solution.

rbclark commented 1 year ago

I have a very similar use-case as @elik-ru, and https://github.com/hotwired/turbo/pull/863 made this an issue that that is once again very relevant. The inability to specify from the server whether a response should be a full page load causes a lot of problems when dealing with handling failure cases for a form submission when inside a modal, since only the server knows whether the page should redirect or render an error, not the client. It seems there are 2 options at the moment, neither of which is ideal as an end user:

  1. Send the whole page so every time and don't use frames at all. (which defeats the purpose of turbo in this use case)
  2. Use the solution described in https://github.com/hotwired/turbo/pull/863#issuecomment-1499571361 (which goes against the turbo motto of "The speed of a single-page web application without having to write any JavaScript.")
zmitic commented 1 year ago

If someone is interested, 204 (no content) status can be used given how rare it is. To jump out of frame, I use this (Symfony):

class TurboRedirectResponse extends Response
{
    public function __construct(string $url)
    {
        parent::__construct(status: 204, headers: ['location' => $url]);
    }
}

The important bit, event listener. All these checks for nullability are because I don't want to risk failures and I am terrible with JS:

document.addEventListener('turbo:submit-end', (event) => {
    let response = event.detail?.fetchResponse?.response;
    let status = response?.status;
    let url = response?.headers?.get('Location') ?? null;

    if (status === 204 && url) {
        Turbo.visit(url, {action: 'advance' }) // this should also have `frame` value, not tested yet
        event.preventDefault();

        return false;
    }
})

And finally controller:

public function doSomething(): Response
{
    if ($shouldControllerRedirect) {
        return new TurboRedirectResponse($url); // should have target frame as second param here
    }

    return new Response('<turbo-frame id="test">This is where form is rendered as usual</turbo-frame>');
}

The idea is far from perfect and right now, issues a full visit but that is only because of missing frame (commented above). But I expect it to work, someone could improve on this idea. I tried to catch 303 in event listener, but it didn't work at all.

elik-ru commented 11 months ago

I took me some time to understand how to make the workaround work perfectly.

First you must understand one thing about layouts. It you don't specify layout in your controller - turbo-rails does it for you with this piece of code:

    layout -> { "turbo_rails/frame" if turbo_frame_request? }

So when doing frame requests it renders special tiny layout, which saves render time and transferred bytes, which is what we want.

But if for some reason you need custom layout and specify it as

layout "admin"
  layout -> { turbo_frame_request? ? "turbo_rails/frame" : "admin" }

Now we have custom full layout by default, and tiny layout for turbo-frame requests. Nice.

Next we come to the break-out problem. That proposed solution kinda worked for me, but not ideally:

document.addEventListener("turbo:frame-missing", function(event) {
    if (event.detail.response.redirected) {
        event.preventDefault()
        event.detail.visit(event.detail.response);
    }
})

It is detected correctly, but Turbo is making full page reload, doing 2 requests in a row. I was digging around, tried to do Turbo.visit(event.detail.response.url);, which was working better (without full page reload), but still doing double requests. I was looking at the code in Turbo and it seems that it was supposed to work, but it was not. Until I realised that thing with layouts. Response was rendered with short layout! So Turbo detects that page head content is different and triggers a full-page reload! That means we must detect somehow this situation and render full layout after a redirect.

So here is the final solution:

class ApplicationController < ActionController::Base
  add_flash_types :turbo_breakout
  layout -> {
    turbo_frame_request? && ! turbo_frame_breakout? ? "turbo_rails/frame" : "application"
  }
  def turbo_frame_breakout?
    flash[:turbo_breakout].present?.tap { flash.delete(:turbo_breakout) }
  end

  ...
  def some_action
     redirect_to target_path, success: "Congratulations!", turbo_breakout: true
  end
end

And same js snippet:

document.addEventListener("turbo:frame-missing", function(event) {
    if (event.detail.response.redirected) {
        event.preventDefault()
        event.detail.visit(event.detail.response);
    }
})

So, what we are doing here?

  1. add_flash_types registers new flash type, so redirect_to can recognise it
  2. we set proper layout. Tiny one for turbo-frame requests, but not when we are trying to break out.
  3. turbo_frame_breakout? checks the value in flash and removes it (otherwise it could be shown with other messages to the user).

Finally, redirect_to target_path, success: "Congratulations!", turbo_breakout: true is doing a redirect setting 2 flash messages. One for the user and the other for choosing correct layout.

Last question: what happens if some redirect occurs without our flash message? Well, it still will be working, with that double load and full-page visit, but still working, so I think it's a good fallback for unexpected cases.

krschacht commented 10 months ago

I was able to override the frame-target using the meta tag. It's explained here in the docs: https://turbo.hotwired.dev/handbook/frames#%E2%80%9Cbreaking-out%E2%80%9D-from-a-frame

In certain, specific cases, you might want the response to a request to be treated as a new, full-page navigation instead, effectively “breaking out” of the frame. The classic example of this is when a lost or expired session causes an application to redirect to a login page. In this case, it’s better for Turbo to display that login page rather than treat it as an error. The simplest way to achieve this is to specify that the login page requires a full-page reload, by including the turbo-visit-control meta tag: ...

elik-ru commented 10 months ago

@krschacht It will make a full-page reload. Meaning 2 requests for the page, and reloading/reevaluating of JS/CSS.

radanskoric commented 5 months ago

One relatively common subcase of this problem is when you want to break out of the frame and redirect the full page back to itself to refresh it. Since the introduction of morphing we also have the refresh stream action which will always apply itself to the full page.

So, that case can be solved relatively simply by having the controller emit the refresh action when the submission is successful:

render turbo_stream: turbo_stream.action(:refresh, "")

(Not yet a built in action, pending https://github.com/hotwired/turbo-rails/pull/595).

I agree with the sentiments some people expressed here that it doesn't feel right to use stream actions as response to a form submission but at the moment that is probably the simplest, most maintainable, solution.