Module: Controllers::TurboSafeRedirect

Extended by:
ActiveSupport::Concern
Included in:
ApplicationController
Defined in:
app/concerns/controllers/turbo_safe_redirect.rb

Overview

Intercepts redirect_to on Turbo Stream form submissions.

Background: when a controller responds to a Turbo Stream form submission
(Accept: text/vnd.turbo-stream.html) with a plain redirect_to, Rails
returns a 303 + Location. Turbo Drive follows the redirect with the SAME
turbo-stream Accept header, so the redirect target's format.turbo_stream
branch is invoked instead of the full HTML page. When that branch renders
contextual streams (infinite scroll, lazy frame fills, partial replacements
targeting DOM ids absent from the source page), the user ends up visually
stranded — the form looks like it submitted but the page doesn't change.

This concern transparently rewrites such redirects into a turbo_stream
response. Two shapes:

  1. Refresh (morph in place). When the redirect target points back at the
    page the user is currently on (same path + query as request.referer),
    we emit <turbo-stream action="refresh" request-id="">. Turbo morphs the
    page in place via the turbo_refreshes_with method: :morph directive
    declared by both layouts (shared/_crm_header_stack and _cms_header_meta),
    preserving scroll and focus. request-id="" clears Turbo's de-dup so
    the issuing tab also receives the refresh — by default Turbo filters out
    refreshes whose request_id matches the current page's.

  2. Redirect (full Drive visit). When the target is a different URL,
    we emit <turbo-stream action="redirect" url="...">. The custom JS
    handler (TurboStreamActionsHelper#redirect) calls Turbo.visit(url),
    which fetches the target with Accept: text/html and renders a clean
    full page.

Plain HTML form submissions, GET-then-redirect flows, and explicit
format.html { redirect_to ... } callers under non-turbo requests are
unaffected — they still get the standard 303 + Location.

Hotwire's stance on this: see https://github.com/hotwired/turbo/issues/138
(closed, "wontfix-ish"). Each project picks one workaround and codifies it.
Heatwave codifies here.

Instance Method Summary collapse

Instance Method Details

#redirect_to(options = {}, response_options = {}) ⇒ void

This method returns an undefined value.

Override of ActionController::Redirecting#redirect_to.

For non-Turbo-Stream requests this just delegates to super. For Turbo
Stream requests it rewrites the response into a 200 turbo_stream tag
(refresh or redirect — see the concern's class docstring) instead of
the standard 303 + Location.

Parameters:

  • options (String, Hash, ActiveRecord::Base, Symbol) (defaults to: {})

    redirect target;
    anything ActionController::Redirecting#_compute_redirect_to_location
    accepts.

  • response_options (Hash) (defaults to: {})

    mirrors the second argument to vanilla
    redirect_to. For Turbo Stream requests, :notice, :alert, and
    :flash are propagated to flash (so they survive the subsequent
    Turbo.visit / refresh GET); :allow_other_host is honored for the
    cross-host gate; :status is dropped (the response is a 200
    turbo_stream tag, not an HTTP 3xx). For non-Turbo-Stream requests it
    is forwarded untouched to super.

Options Hash (response_options):

  • :notice (String)

    flash[:notice] to set on the
    forthcoming request.

  • :alert (String)

    flash[:alert] to set on the
    forthcoming request.

  • :flash (Hash{Symbol=>String})

    arbitrary flash
    keys to set on the forthcoming request.

  • :allow_other_host (Boolean) — default: false

    skip the
    cross-host security gate. Mirrors vanilla redirect_to.

  • :status (Symbol, Integer)

    HTTP status (only
    honored on the non-Turbo-Stream super path).



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'app/concerns/controllers/turbo_safe_redirect.rb', line 70

def redirect_to(options = {}, response_options = {})
  return super unless _turbo_stream_redirect_request?

  url = _compute_redirect_to_location(request, options)
  # Mirror Rails' security gate: cross-host redirects must be explicitly
  # allowed. Vanilla `redirect_to` enforces this — we must too.
  unless response_options[:allow_other_host] || _url_host_allowed?(url)
    raise ActionController::Redirecting::UnsafeRedirectError,
          "Unsafe redirect to #{url.to_s.truncate(100).inspect}"
  end

  _turbo_safe_redirect_set_flash(response_options)

  # Flag the request for {Controllers::TurboStreamFlashable} so it doesn't
  # demote `flash` → `flash.now`. Both refresh (morph) and redirect (Drive
  # visit) trigger a follow-up GET on the same browser session — flash
  # must persist via session into that GET.
  @_turbo_safe_redirect_emitted = true

  # Force Content-Type to text/vnd.turbo-stream.html. When this override
  # fires from inside `respond_to do |format| format.html { redirect_to … } end`,
  # Rails has already set the response Content-Type to text/html. The
  # turbo-rails `render turbo_stream:` renderer only assigns the
  # turbo_stream MIME when `media_type.nil?` (engine.rb), so without this
  # reset the response body is a `<turbo-stream>` tag served as text/html.
  # Turbo's stream observer sniffs Content-Type before intercepting; on
  # text/html it lets the response fall through to FormSubmission, which
  # then errors with "Form responses must redirect to another location"
  # because the response is 200 + non-redirect. Result: button stuck in
  # "Sending…", no navigation, user clicks again, duplicate submissions.
  response.content_type = Mime[:turbo_stream].to_s

  if _turbo_redirect_targets_referer?(url)
    # Morph the page the user is already viewing — preserves scroll/focus,
    # no full-page repaint. `request_id: nil` omits the request-id
    # attribute so Turbo's de-dup (which would otherwise compare against
    # the current page's request_id and skip) doesn't fire.
    render turbo_stream: turbo_stream.refresh(request_id: nil)
  else
    render turbo_stream: turbo_stream.redirect(url)
  end
end