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:
-
Refresh (morph in place). When the redirect target points back at the
page the user is currently on (same path + query asrequest.referer),
we emit<turbo-stream action="refresh" request-id="">. Turbo morphs the
page in place via theturbo_refreshes_with method: :morphdirective
declared by both layouts (shared/_crm_header_stackand_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. -
Redirect (full Drive visit). When the target is a different URL,
we emit<turbo-stream action="redirect" url="...">. The custom JS
handler (TurboStreamActionsHelper#redirect) callsTurbo.visit(url),
which fetches the target withAccept: text/htmland 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
-
#redirect_to(options = {}, response_options = {}) ⇒ void
Override of ActionController::Redirecting#redirect_to.
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.
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( = {}, = {}) return super unless _turbo_stream_redirect_request? url = _compute_redirect_to_location(request, ) # Mirror Rails' security gate: cross-host redirects must be explicitly # allowed. Vanilla `redirect_to` enforces this — we must too. unless [: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() # 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 |