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/views/settings/show.html.erb
pitfall
refactored
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<div class="mx-auto max-w-2xl p-8">
  <h1 class="text-2xl font-bold">Settings</h1>

  <% if notice %>
    <div class="mt-4 rounded border-2 border-green-200 bg-green-50 p-3 text-green-800"
         data-turbo-temporary><%= notice %></div>
  <% end %>

  <%= form_with url: settings_path, method: :patch do |form| %>
    <div class="mt-6">
      <%= form.label :display_name, class: "block font-medium" %>
      <div class="mt-1 flex items-center gap-3">
        <%= form.text_field :display_name, value: @display_name,
              class: "w-64 rounded border-2 border-gray-300 bg-white p-2 inset-shadow-sm" %>
        <%= form.button "Save changes", class: "rounded bg-gray-900 px-4 py-2 text-white hover:bg-gray-800" %>
      </div>
    </div>
  <% end %>

  <div class="mt-8">
    <h2 class="font-medium text-red-700">Danger zone</h2>
    <div class="mt-1"
         data-controller="confirm"
         data-confirm-hide-class="hidden">
      <button type="button" class="rounded border-2 border-red-300 bg-white px-4 py-2 text-red-700 hover:border-red-400"
              data-confirm-target="trigger" data-action="confirm#arm">
        Delete account…
      </button>
      <div class="hidden" data-confirm-target="confirmation">
        <div class="flex items-center gap-3">
          <span class="text-sm text-red-700">Delete your account and all its data?</span>
          <button type="button" class="rounded bg-red-600 px-4 py-2 text-white">Yes, delete</button>
          <button type="button" class="rounded border-2 border-gray-300 px-4 py-2"
                  data-action="confirm#disarm">
            Cancel
          </button>
        </div>
      </div>
    </div>
  </div>
</div>
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