Skip to main content

Friday, February 13, 2026

Maquina Components 0.4.0: Taming Turbo

Mario Alberto Chávez Cárdenas
Maquina Components 0.4.0 release with Turbo Drive and Morph compatibility

I was working on a Rails application—standard CRUD with a sidebar and a few interactive menus. Everything worked on first load. Then I navigated away and came back. The sidebar was gone. I opened a dropdown, clicked a Turbo link, hit the back button. The dropdown was still open, sitting there on top of a page that had already moved on.

If you’ve built anything with Turbo and Stimulus beyond basic forms, you’ve likely seen this. Components work fine on full page loads, but Turbo introduces a different lifecycle. Pages get cached mid-state, morphs overwrite client-side changes with stale server HTML, and your UI ends up stuck in states it should have left behind.

Fixing this in Maquina Components is what version 0.4.0 is about.

The Teardown Pattern

The core problem is described well by Better Stimulus. When Turbo navigates away from a page, it takes a snapshot of the DOM before leaving. When the user returns, Turbo shows that snapshot first. Any DOM changes your Stimulus controllers made—open menus, expanded panels, loading classes—get frozen into the cache.

The standard Stimulus disconnect callback handles general cleanup, but it doesn’t distinguish between “the element was removed from the DOM” and “Turbo is about to cache this page.” You need both.

The Teardown pattern adds a teardown method to controllers, triggered by Turbo’s turbo:before-cache event. Every controller that manipulates the DOM can opt in, resetting its visual state before Turbo takes the snapshot. This keeps disconnect clean for general lifecycle concerns and gives Turbo-specific rollback its own dedicated path.

This release applies that pattern across the interactive components in the library.

The sidebar was the hardest to get right. It had three separate issues interacting with each other.

Random IDs broke morphing. The sidebar generated IDs like sidebar-a3f9b2 on every render. Turbo’s idiomorph algorithm matches elements by ID—when the ID changes every time, idiomorph can’t find the element and treats it as new. Every morph was destroying and recreating the sidebar from scratch. The fix: deterministic IDs. sidebar-left and sidebar-right, consistent across renders. The sidebar provider also gets a stable ID (sidebar-provider by default).

Morphs overwrote client state. The sidebar stores its open/closed state in a cookie so it persists across page loads. During a Turbo morph, the server sends back HTML with the default state—it doesn’t know about the cookie. Idiomorph applies the server HTML, and the sidebar collapses even though the user had it open.

The fix adds a turbo:before-morph-element listener with a _morphing guard flag. When a morph happens, the controller reads the cookie (the source of truth on the client), reasserts the correct state, and strips the sidebar-loading class that the server HTML reintroduces.

Layout shift on desktop. When Stimulus initialized and switched the sidebar from its mobile offcanvas mode to the desktop collapsible mode, there was a visible jump. The transition happened after the browser had already painted. This release smooths that handoff so the mode switch doesn’t cause a flash.

The Yield Trap

The second category of fixes has nothing to do with Turbo. It’s a Rails rendering behavior that caught me off guard.

Nine partials in the library used the standard block pattern:

<%= render "components/card/description" do %>
  <p>Custom HTML</p>
<% end %>

This works when you always pass a block. But render the partial without a block and yield inside it doesn’t return nothing—it renders the entire page’s content into the partial. Rails treats the missing block as a signal to yield the page-level content instead.

The result: components rendering the full page body inside a card title or a toast message. It only shows up in specific usage patterns, and when it does, the output looks completely wrong with no obvious cause.

The fix replaces yield with an explicit content: parameter in all nine affected partials:

  • card/title, card/description
  • alert/title, alert/description
  • toast/title, toast/description
  • combobox/label, toast (main), toaster

The five toast helper methods no longer accept blocks either.

Breaking Changes

This is a minor version bump with breaking changes:

  • Block syntax removed for the 9 partials listed above. Use content: capture { ... } instead of do ... end.
  • Toast helpers no longer accept blocks. Use the content: parameter.
  • Sidebar IDs changed from sidebar-<random_hex> to sidebar-left / sidebar-right.
  • Sidebar provider now has a stable id attribute (sidebar-provider by default).

Migration

The content parameter change is mechanical. Find every block-style call to the affected partials and wrap the content with capture:

<%= render "components/card/description" do %>
  <p>Custom HTML</p>
<% end %>

<%= render "components/card/description",
      content: capture { %>
  <p>Custom HTML</p>
<% } %>

For sidebar IDs, if you reference specific sidebar element IDs in JavaScript or tests, update them to sidebar-left or sidebar-right.

Upgrading

bundle update maquina_components

What This Reinforced

Turbo is not a transparent layer over page loads. It’s a different execution model. Any Stimulus controller that touches the DOM needs to account for caching, morphing, and the gap between what the server renders and what the client has changed since. The Teardown pattern should be the default starting point for any controller that does more than read values.

The yield behavior in Rails partials was a genuine surprise. It’s documented, but it’s a quiet trap when you have optional block content. Explicit parameters are safer.

Documentation

Source

Work Together

Need help with your Rails project?

I'm Mario Alberto Chávez—Rails architect available for consulting, AI integration, and code review.