Pitfalls & Burns

Turbo Drive

Cache-Back Guarantee

Temporary UI, like flash messages and armed confirmations, frozen into Turbo's cached snapshot and brought back by the Back button.

app/javascript/controllers/delete_account_controller.js
pitfall
refactored
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Controller } from "@hotwired/stimulus"

// Arms and disarms the delete-account confirmation.
export default class extends Controller {
  static targets = [ "trigger", "confirmation" ]
  static classes = [ "hide" ]

  arm() {
    this.triggerTarget.classList.add(this.hideClass)
    this.confirmationTarget.classList.remove(this.hideClass)
  }

  disarm() {
    this.triggerTarget.classList.remove(this.hideClass)
    this.confirmationTarget.classList.add(this.hideClass)
  }
}
The settings page before the fix. Back brings the flash and the armed confirmation with it.
The settings page after the fix. Back returns a clean page.

The scenario

We have a settings page. Saving the form redirects back with a "Settings saved." flash. Further down, a "Delete account…" button arms a two-step confirmation: click it and it's replaced by "Yes, delete" and "Cancel".

The burn

Save the form, click "Delete account…", visit another page, then press Back. The stale flash is there again, and so is the delete confirmation, armed, with "Yes, delete" one stray click away.

When we leave a page, Turbo Drive takes a snapshot of it. It uses that snapshot for Back and Forward, and as an instant preview when we link to the page again. The snapshot is a faithful copy of the page exactly as we left it, and nobody tidies up for it unless we ask. Nobody wants a one-time notice or a half-finished dangerous action restored, but that's what we get.

The fix

Two small fixes, of two different shapes.

The flash is server-rendered, one-time markup. In the refactored show.html.erb it carries data-turbo-temporary, and Turbo removes the element before it takes the snapshot. No JavaScript at all.

The confirmation is state the user created in the browser, so the controller that created it cleans it up. confirm_controller.js is the same controller as delete_account_controller.js with one addition: disconnect() calls disarm(). Turbo takes its snapshot after controllers have disconnected, so the cached copy is always disarmed.

When neither fits, there are two more tools. A turbo:before-cache listener can prepare the page for caching when the state isn't owned by a Stimulus controller. And a page too sensitive to cache at all can opt out with a turbo-cache-control meta tag set to no-preview or no-cache.

Why it matters

Any temporary UI is a candidate for this: flashes, confirmations, open menus and modals, revealed secrets, spinners. The question to ask of each is whether it makes sense on a page someone returns to. If not, it should be gone before the snapshot is taken.

It's the same rule as the teardown in disconnect() for a third-party widget: leave the page as you found it. Turbo's cache rewards controllers that clean up after themselves and punishes ones that don't, usually on the Back button, where it's hardest to notice in testing.

Read More