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
Section titled “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
Section titled “The complete list of moving parts”Each is documented at its source. The pointers here exist so you can find them.
Server-side
Section titled “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
Section titled “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
Section titled “View-level escape patterns”Three forms of “this link/form should escape the enclosing frame and do a top-level Drive visit”:
<turbo-frame target="_top">— attribute on the frame element. Affects everything inside.<div data-turbo-frame="_top">— wrapper div. Affects everything inside (Turbo walks up the DOM from each link/form). Preferred for tab content — the innerturbo_frame_tagexists so Turbo can extract its contents into the outer lazy-load frame; thetargetattribute on the inner frame is discarded on the swap and would only confuse a reader.data: { turbo_frame: '_top' }on a singlelink_to/button_to— when only one element needs to break out.
Recipes — “I want X”
Section titled “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
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-missingrecovery inturbo_stream_actions.jsdoes 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"endThis 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.
What NOT to do
Section titled “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.redirectand the empty-shell trap) useadvanceas of 2026-05-12. - Don’t add
target="_top"to the outer frame intab_panel. The outer frame’stargetonly takes effect for its own contents post-swap; the inner frame’s contents are what carry navigation intent. Use the wrapper div or per-linkdata-turbo-frame="_top"instead. - Don’t use
pushState(null, '', url)in a Stimulus controller. It strips Turbo Drive’srestorationIdentifierand breaks back-button restoration. UsereplaceState(window.history.state, '', url)— seeturbo_tabs_controller.js#activatefor the canonical version. There’s a source-level pin test guarding this intest/system/crm/customer_tab_navigation_test.rb. - Don’t call
turbo_stream.redirect(...)when plainredirect_towould do —TurboSafeRedirecthandles 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_emittedprecisely soTurboStreamFlashablecan coordinate without string matching (an HTML payload may legitimately containaction="redirect"as content).
Regression coverage
Section titled “Regression coverage”test/system/crm/customer_tab_navigation_test.rb— pins thereplaceState(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— pinsaction: 'advance'inStreamActions.redirectAND the empty-shell trap (source-level greps overturbo_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); seedoc/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
Section titled “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.