Pitfalls & Burns

Turbo Drive

What's The Disconnect?

Wiring up a third-party library in connect() without tearing it down. Navigate away and back, and watch the calendars pile up.

app/javascript/controllers/date_picker_controller.js
pitfall
refactored
1
2
3
4
5
6
7
8
9
import { Controller } from "@hotwired/stimulus"
import flatpickr from "flatpickr"

// Attaches an inline flatpickr calendar to the date field.
export default class extends Controller {
  connect() {
    flatpickr(this.element, { inline: true })
  }
}
The appointment page before the fix. Every trip away and back adds another calendar.

The scenario

We need a date picker on an appointment form. We reach for Flatpickr, write a small Stimulus controller that imports it, and attach it to the input in connect(). The page loads, the calendar appears, and everything looks done.

The burn

Now visit another page and press the browser's Back button. There are two calendars. Do it again and there are three. Only the newest one works; the others are leftovers that look real and do nothing.

Turbo Drive is the reason. It doesn't reload the page on navigation; it swaps the body and keeps a snapshot of the page you left, so Back can show it instantly. That snapshot is the page as enhanced: it already contains the calendar Flatpickr injected. When Turbo restores it, Stimulus connects the controller again, and connect() builds a second calendar next to the cached one.

This bug survives into production easily. Clicking a link to the page fetches fresh HTML from the server and shows one clean calendar, which is how we tend to test. Only history navigation, Back and Forward, renders the cached snapshot, and that's what users do.

Not every library fails this visibly. Some quietly leave a dead widget on the page, and the only report you get is "it stops working when I go back."

The fix

Give connect() a partner. flatpickr_controller.js keeps the instance it creates and calls destroy() on it in disconnect(), which puts the input back to plain markup. Stimulus calls disconnect() whenever the element leaves the page, whether that's a Turbo navigation or a direct removal from the DOM, so one method covers both.

The view barely changes: show.html.erb swaps data-controller="date-picker" for data-controller="flatpickr".

Why it matters

The timing works in our favour. Turbo takes its snapshot after the old body has been swapped out and every disconnect() has run, so teardown in disconnect() cleans the cached copy as well as the live page. We don't need a separate cache hook for this. disconnect() isn't only about memory: it's what keeps Turbo's cache honest.

This is also the class of bug that pushes teams to turn Turbo Drive off. The durable fix is honoring the lifecycle: anything you set up in connect(), you undo in disconnect().

When cleanup isn't managed by a Stimulus controller, the turbo:before-cache event is the place to do it.

Read More