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.

The layers, in one diagram

┌──────────────────────────────────────────────────────────────────────┐
│ 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).

The complete list of moving parts

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

Server-side

Layer File What it does
Controllers::TurboSafeRedirect app/concerns/controllers/turbo_safe_redirect.rb Intercepts redirect_to on turbo-stream requests; emits turbo_stream.refresh (target == referer) or turbo_stream.redirect otherwise.
Controllers::TurboStreamFlashable app/concerns/controllers/turbo_stream_flashable.rb after_action that appends _flash.turbo_stream.erb to turbo-stream responses. Skips redirects so flash survives into the follow-up GET.
TurboFrameErrorHandling app/controllers/concerns/turbo_frame_error_handling.rb Per-controller rescue_from StandardError that renders an inline error partial inside a frame. Opt-in via include + turbo_frame_id.
ApplicationHelper#tab_frame_id app/helpers/application_helper.rb Returns 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_id same Always 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_tab app/helpers/crm_helper.rb The CRM tab layout: vertical nav + single lazy-load <turbo-frame> content area, Stimulus-wrapped.

Client-side

Layer File What it does
StreamActions.redirect app/javascript/turbo_stream_actions.js Custom action used by TurboSafeRedirect. Turbo.visit(url, { action: 'advance' }).
turbo:frame-missing recovery same Renders an in-frame error UI on !ok responses, otherwise replays the response as a Drive visit.
turbo:before-frame-render empty-shell trap same Detects 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 recovery same Network-level failure on a frame fetch — renders the "Connection problem" alert + Retry button.
isTabFrameBreakout() helper same Detects 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 setup client/js/crm/setup/turbo_config.js Enables 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 controller app/javascript/controllers/turbo_tabs_controller.js Activates tabs without a Drive visit, writes ?tab=<id> via replaceState (preserves Turbo's restorationIdentifier), reconciles after popstate, recovers from BFCache mismatches.

View-level escape patterns

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.

Recipes — "I want X"

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

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

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

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

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)

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

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

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.

What NOT to do

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

Regression coverage

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

History

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.