Skip to content

Turbo Navigation

The single reference for how navigation works in this codebase. If you’re about to add a redirect_to, a turbo_frame_tag, a custom Turbo Stream, or a “this link should escape its frame” attribute — start here.

┌──────────────────────────────────────────────────────────────────────┐
│ User click / form submit │
└───────────────────────────────────┬──────────────────────────────────┘
┌─────────────────────┴─────────────────────┐
│ Is the element inside a <turbo-frame>? │
└─────────────────────┬─────────────────────┘
yes │ no
┌──────────────────┐ │ ┌──────────────────┐
│ data-turbo-frame │◀────────┴────────▶│ Turbo Drive form │
│ ="_top" set on │ │ submission / │
│ link/form/parent?│ │ link click │
└────────┬─────────┘ └────────┬─────────┘
│ │
yes ┌───┴───┐ no │
│ │ │
▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌───────────────┐
│ Top-level│ │Frame fetch│ │ Drive request │
│ Drive │ │with Turbo-│ │ with text/html│
│ visit │ │Frame hdr │ │ Accept hdr │
└────┬────┘ └─────┬─────┘ └───────┬───────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────┐
│ Server response │
└────────────────────┬─────────────────────┘
┌────────────────────┴────────────────────────┐
│ Is Accept: text/vnd.turbo-stream.html? │
└────────────────────┬────────────────────────┘
yes │ no
┌────────────────────┐ │ ┌────────────────────┐
│ TurboSafeRedirect │ │ │ Plain redirect_to │
│ intercepts │ │ │ → 303 + Location │
│ redirect_to: │ │ │ → Turbo follows │
│ │ │ │ → Drive visit │
│ same URL → refresh │ │ │ (advance) │
│ diff URL → redirect│ │ └────────────────────┘
└─────────┬──────────┘ │
│ │
▼ ▼
┌──────────────────────┐ ┌───────────────┐
│ Client receives │ │ Frame found? │
│ <turbo-stream> │ └───────┬───────┘
│ ─ action=refresh │ yes │ no
│ ─ action=redirect │ │
│ (custom, action= │ ┌──────┴────────────┐
│ advance) │ │ Empty lazy shell? │
└──────────────────────┘ └─────┬─────────────┘
yes│no
┌────────┴────────┐
│ Trap → Drive │
│ visit (advance) │
└─────────────────┘
Bare `no frame` → turbo:frame-missing handler runs visit(response).

Each is documented at its source. The pointers here exist so you can find them.

LayerFileWhat it does
Controllers::TurboSafeRedirectapp/concerns/controllers/turbo_safe_redirect.rbIntercepts redirect_to on turbo-stream requests; emits turbo_stream.refresh (target == referer) or turbo_stream.redirect otherwise.
Controllers::TurboStreamFlashableapp/concerns/controllers/turbo_stream_flashable.rbafter_action that appends _flash.turbo_stream.erb to turbo-stream responses. Skips redirects so flash survives into the follow-up GET.
TurboFrameErrorHandlingapp/controllers/concerns/turbo_frame_error_handling.rbPer-controller rescue_from StandardError that renders an inline error partial inside a frame. Opt-in via include + turbo_frame_id.
ApplicationHelper#tab_frame_idapp/helpers/application_helper.rbReturns tab-content-<controller>, echoing the caller’s Turbo-Frame header only when the URL carries the parent’s id route param.
ApplicationHelper#embedded_tab_frame_idsameAlways echoes the caller’s tab-content-* header. For dual-purpose views that render full-screen or inside ANY tab.
CrmHelper#tab_panel + render_tab_link + render_tab_panel + turbo_stream_activate_tabapp/helpers/crm_helper.rbThe CRM tab layout: vertical nav + single lazy-load <turbo-frame> content area, Stimulus-wrapped.
LayerFileWhat it does
StreamActions.redirectapp/javascript/turbo_stream_actions.jsCustom action used by TurboSafeRedirect. Turbo.visit(url, { action: 'advance' }).
turbo:frame-missing recoverysameRenders an in-frame error UI on !ok responses, otherwise replays the response as a Drive visit.
turbo:before-frame-render empty-shell trapsameDetects when an in-frame submit redirects to a show page whose frame is just a lazy-load shell. Promotes to a Drive visit (advance).
turbo:fetch-request-error recoverysameNetwork-level failure on a frame fetch — renders the “Connection problem” alert + Retry button.
isTabFrameBreakout() helpersameDetects cross-controller breakouts (e.g. a contact card inside a customer tab linking to /contacts/:id) so the recovery handler can downgrade them from warning to debug.
Other custom StreamActions.*turbo_stream_actions.js~20 actions: toast, set_value, bs_modal_show/hide, clear_modal, track_download, open_url, set_tab_param, etc. Each documented at its definition.
Turbo Drive setupclient/js/crm/setup/turbo_config.jsEnables Drive, disables prefetch, serializes document.startViewTransition (fixes overlapping renders), syncs navbar permanent element, recovers from exception pages stuck in history after popstate, scrolls to error summary after 4xx form re-renders.
turbo-tabs Stimulus controllerapp/javascript/controllers/turbo_tabs_controller.jsActivates tabs without a Drive visit, writes ?tab=<id> via replaceState (preserves Turbo’s restorationIdentifier), reconciles after popstate, recovers from BFCache mismatches.

Three forms of “this link/form should escape the enclosing frame and do a top-level Drive visit”:

  1. <turbo-frame target="_top"> — attribute on the frame element. Affects everything inside.
  2. <div data-turbo-frame="_top"> — wrapper div. Affects everything inside (Turbo walks up the DOM from each link/form). Preferred for tab content — the inner turbo_frame_tag exists so Turbo can extract its contents into the outer lazy-load frame; the target attribute on the inner frame is discarded on the swap and would only confuse a reader.
  3. data: { turbo_frame: '_top' } on a single link_to / button_to — when only one element needs to break out.
Section titled “A user clicks a link inside a lazy-loaded tab pane and should be carried to a different resource as a full-page Drive visit”

Two equivalent options, both fine:

<%# Option A: wrap the section %>
<%= turbo_frame_tag tab_frame_id do %>
<div data-turbo-frame="_top">
<%= link_to "Web Visits", visits_path(...) %>
<%= link_to "Customers", search_path(...) %>
</div>
<% end %>
<%# Option B: tag each link %>
<%= turbo_frame_tag tab_frame_id do %>
<%= link_to "Web Visits", visits_path(...), data: { turbo_frame: "_top" } %>
<% end %>

If you forget either, two safety nets catch you:

  • For links: turbo:frame-missing recovery in turbo_stream_actions.js does a Drive visit on response.
  • For forms-then-redirects: the empty-shell trap (turbo:before-frame-render) promotes to a Drive visit.

Both are AppSignal-reported as TurboFrameMissing / TurboEmptyFrameShell. Use the safety nets as smoke detectors, not as the design. If you see your view’s frame in AppSignal, fix the source.

A user submits a form inside a tab pane and the controller calls redirect_to

Section titled “A user submits a form inside a tab pane and the controller calls redirect_to”

Do nothing special. Controllers::TurboSafeRedirect (included in ApplicationController) detects the turbo-stream Accept header, emits <turbo-stream action="redirect" url=…>, and the custom JS handler issues a Drive advance visit. Back button works, flash messages survive, no controller-side ceremony.

The only caveat: include the matching data-turbo-frame="_top" (see recipe above) so the form submits as a top-level Drive submission rather than scoping to the frame. If you forget, the empty-shell trap will still recover you, but at the cost of a console warning + AppSignal noise.

A controller wants to morph the current page in place after a successful action

Section titled “A controller wants to morph the current page in place after a successful action”

Make sure the layout declares morphing:

<%= turbo_refreshes_with method: :morph %> <%# already set in CRM + CMS layouts %>

Then in the controller, redirect_to back to where the user was. TurboSafeRedirect notices that the target path+query equals request.referer and emits <turbo-stream action="refresh" request-id=""> instead of a redirect. The request_id="" empties Turbo’s de-dup so the issuing tab also receives the refresh.

Use this for “delete this row” / “toggle this checkbox” / “favorite this thing” actions where the page is mostly unchanged after success.

A controller wants to do a full Drive visit (advance history) after a successful action

Section titled “A controller wants to do a full Drive visit (advance history) after a successful action”

Same as the previous recipe — just call redirect_to with a URL that’s different from request.referer. TurboSafeRedirect emits the redirect form, the custom handler does an advance visit.

You should rarely call turbo_stream.redirect(url) directly — the only case is when you’re already rendering a turbo-stream collection and need to fold a redirect into it:

render turbo_stream: [
turbo_stream.append(:foo, partial: "foo"),
turbo_stream.redirect(some_path)
]

For plain “create the record, redirect to it”, just use redirect_to.

A controller wants to render error stream actions inline without leaving the page

Section titled “A controller wants to render error stream actions inline without leaving the page”

Use flash.now[:error] and render an empty turbo-stream:

flash.now[:error] = "Could not save"
render turbo_stream: turbo_stream.replace(:my_form, partial: "form")

TurboStreamFlashable will append the flash partial to the response body so the toast/notice appears. Don’t flash[:error] = ... on a non-redirect turbo-stream response — it gets demoted to flash.now by the same concern anyway, but the intent is clearer if you write flash.now directly.

An endpoint is meant to be embeddable in ANY tab (search/list views, generic pages)

Section titled “An endpoint is meant to be embeddable in ANY tab (search/list views, generic pages)”

Use embedded_tab_frame_id instead of tab_frame_id:

<%= turbo_frame_tag embedded_tab_frame_id do %>
<% end %>

Unlike tab_frame_id, this always echoes the caller’s Turbo-Frame: tab-content-* header instead of gating on a parent_id route param. The canonical example is app/views/searches/search_and_show.html.erb.

A controller’s frame rendering can raise — I want a graceful in-frame error instead of a blank pane

Section titled “A controller’s frame rendering can raise — I want a graceful in-frame error instead of a blank pane”

Include the frame error concern:

class MyFrameController < ApplicationController
include TurboFrameErrorHandling
turbo_frame_id "my-frame-id"
end

This wraps every action with a rescue_from StandardError that renders shared/_turbo_frame_error.html.erb inside the frame. Optional — the global turbo:frame-missing handler will render a generic error UI even without it.

A custom Turbo Stream action would be useful

Section titled “A custom Turbo Stream action would be useful”

Add it to app/javascript/turbo_stream_actions.js. Document the turbo_stream.<name>(...) invocation pattern in the comment above the definition. Append the helper signature to app/helpers/turbo_stream_actions_helper.rb if it needs typed arguments.

  • Don’t Turbo.visit(url, { action: 'replace' }) in app code without a specific reason. Replace silently breaks the back button. The two server-driven entry points (StreamActions.redirect and the empty-shell trap) use advance as of 2026-05-12.
  • Don’t add target="_top" to the outer frame in tab_panel. The outer frame’s target only takes effect for its own contents post-swap; the inner frame’s contents are what carry navigation intent. Use the wrapper div or per-link data-turbo-frame="_top" instead.
  • Don’t use pushState(null, '', url) in a Stimulus controller. It strips Turbo Drive’s restorationIdentifier and breaks back-button restoration. Use replaceState(window.history.state, '', url) — see turbo_tabs_controller.js#activate for the canonical version. There’s a source-level pin test guarding this in test/system/crm/customer_tab_navigation_test.rb.
  • Don’t call turbo_stream.redirect(...) when plain redirect_to would do — TurboSafeRedirect handles the rewrite. Direct callers are listed in this file; new ones should justify themselves.
  • Don’t sniff response body strings to detect TurboSafeRedirect responses. The concern sets @_turbo_safe_redirect_emitted precisely so TurboStreamFlashable can coordinate without string matching (an HTML payload may legitimately contain action="redirect" as content).
  • test/system/crm/customer_tab_navigation_test.rb — pins the replaceState(window.history.state, ...) contract.
  • test/system/turbo_navigation_test.rb — Bootstrap dropdown survival across Drive navigations.
  • test/system/crm/back_button_after_redirect_test.rb — pins action: 'advance' in StreamActions.redirect AND the empty-shell trap (source-level greps over turbo_stream_actions.js); smoke-tests that the source attributions tab renders the outer tab-panel scaffold. A live POST → redirect → back system test is blocked by a Warden test-mode infra gap (lazy-load frame fetches arrive without the test session cookie); see doc/tasks/202605121329_TURBO_NAVIGATION_FOLLOWUPS.md § 6.

If you add a navigation behavior, add a test in test/system/. If you find a navigation bug in production, add a test before fixing it — the layered nature of this stack makes it easy to “fix” one path and miss another.

The layered design is the result of years of patching real bugs. The file-level docstrings on each layer describe the bug each one was added to solve. Read them before consolidating — most layers are defensive against a real Turbo edge case, not over-engineering.

The biggest single source of confusion is that a server-side change can silently neutralize a client-side change (and vice versa). The 2026-05-12 back-button regression took two fixes: the obvious one (a view-level data-turbo-frame="_top" wrapper) didn’t help because the actual code path was a turbo-stream redirect, not a normal form submission. The fix was a one-line change in StreamActions.redirect.

When debugging an unexpected navigation, instrument first:

const orig = Turbo.session.visit.bind(Turbo.session);
Turbo.session.visit = function(url, opts) {
console.trace('Turbo.visit', url, opts);
return orig(url, opts);
};

That tells you which layer is calling visit() and with what action. Everything else is downstream.