Ruby on Rails

Why Delete Buttons Stop Working in Rails 7+ with Turbo Drive

Mar 01, 2026 13 min read


TL;DR: Chrome silently suppresses window.confirm() dialogs after Turbo Drive AJAX navigations. Your delete buttons appear dead — no dialog, no error, nothing in server logs. The fix: override Turbo.config.forms.confirm with a custom HTML <dialog> element. 


The Fix

Add this to your app/javascript/application.js, after the Turbo import:

import "@hotwired/turbo-rails"
import { Turbo } from "@hotwired/turbo-rails"

// Replace window.confirm() with a custom HTML <dialog>.
// Chrome suppresses native confirm() after Turbo Drive navigations.
Turbo.config.forms.confirm = (message, formElement, submitter) => {
  return new Promise((resolve) => {
    const dialog = document.createElement("dialog")
    dialog.classList.add("turbo-confirm-dialog")
    dialog.innerHTML = `
      <div class="confirm-content">
        <p class="confirm-message">${message}</p>
        <div class="confirm-actions">
          <button type="button" class="confirm-cancel">Cancel</button>
          <button type="button" class="confirm-ok">Confirm</button>
        </div>
      </div>
    `

    document.body.appendChild(dialog)

    const cleanup = (result) => {
      dialog.close()
      dialog.remove()
      resolve(result)
    }

    dialog.querySelector(".confirm-cancel").addEventListener("click", () => cleanup(false))
    dialog.querySelector(".confirm-ok").addEventListener("click", () => cleanup(true))
    dialog.addEventListener("click", (e) => { if (e.target === dialog) cleanup(false) })
    dialog.addEventListener("cancel", (e) => { e.preventDefault(); cleanup(false) })

    dialog.showModal()
  })
}

Add this CSS (anywhere in your stylesheet):

.turbo-confirm-dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  margin: 0;
  border: none;
  border-radius: 0.75rem;
  padding: 0;
  max-width: 24rem;
  width: 90vw;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  z-index: 9999;
}

.turbo-confirm-dialog::backdrop {
  background: rgba(0, 0, 0, 0.4);
  backdrop-filter: blur(2px);
}

.confirm-content {
  padding: 1.5rem;
}

.confirm-message {
  font-size: 0.9375rem;
  color: #1f2937;
  line-height: 1.5;
  margin: 0 0 1.25rem 0;
}

.confirm-actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
}

.confirm-cancel,
.confirm-ok {
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
  font-weight: 500;
  border: none;
  border-radius: 0.375rem;
  cursor: pointer;
  transition: background 0.15s;
}

.confirm-cancel {
  background: #f3f4f6;
  color: #374151;
}

.confirm-cancel:hover {
  background: #e5e7eb;
}

.confirm-ok {
  background: #dc2626;
  color: white;
}

.confirm-ok:hover {
  background: #b91c1c;
}

Done. Every data-turbo-confirm in your app now uses this dialog. Zero changes to your views or controllers.

Note on Turbo versions: If you're on Turbo 7.x (pre-8), use Turbo.setConfirmMethod() instead of Turbo.config.forms.confirm. See the deprecated API section for details.


Reproducing the Bug

The Symptoms

You're building a standard Rails 7+ application. You add a delete button using the idiomatic Rails helper:

<%= button_to "Delete", post_path(@post),
    method: :delete,
    data: { turbo_confirm: "Are you sure you want to delete this post?" } %>

Rails generates this HTML:

<form method="post" action="/posts/42">
  <input type="hidden" name="_method" value="delete">
  <button data-turbo-confirm="Are you sure you want to delete this post?" type="submit">Delete</button>
  <input type="hidden" name="authenticity_token" value="abc123...">
</form>

The HTML is correct. Turbo is loaded. Everything looks right.

But when you click Delete — nothing happens.

No confirmation dialog. No request in the Rails server logs. No JavaScript errors in the console. The button simply does not respond.

The Maddening Pattern

Here's what makes this bug difficult to diagnose:

ScenarioDoes Delete Work?
First visit after hard refresh (Ctrl+Shift+R)✅ Usually yes
After navigating to 2-3 other pages via sidebar/links❌ No
In Incognito mode (fresh session)✅ Yes (until you navigate)
With data-turbo="false" on the form✅ Yes (but defeats the purpose of Turbo)
After a full page reload (F5)✅ Yes (resets the counter)

This "it works sometimes" pattern sends developers down rabbit holes — checking CSRF tokens, Turbo configuration, Stimulus controllers, event listeners — none of which are the problem.


What's Actually Happening

The failure occurs in six discrete steps. Understanding each one is critical.

Step 1: Turbo Drive Navigates via AJAX

When you click a link in a Turbo-enabled app (which is every link by default in Rails 7+), Turbo Drive intercepts the click. Instead of a full page load, it:

  1. Makes a fetch() request for the new page
  2. Replaces the <body> content with the response
  3. Calls history.pushState() to update the browser URL
  4. Does NOT reload the page — the JavaScript context, event listeners, and timers persist

This is what makes Turbo Drive fast. It's also what triggers Chrome's suppression logic.

Step 2: User Clicks Delete

The user clicks the <button> element inside the <form>. The browser fires a click event, which triggers a submit event on the form.

Step 3: Turbo Intercepts the Form Submission

Turbo listens for submit events at the document level. When it catches one, it creates a FormSubmission object and checks for a confirmation message.

Here's the relevant Turbo source code (simplified):

// Inside Turbo's FormSubmission class
async start() {
  const confirmationMessage = getAttribute(
    "data-turbo-confirm",
    this.submitter,      // checks the <button> first
    this.formElement     // then checks the <form>
  );

  if (typeof confirmationMessage === "string") {
    const confirmMethod = Turbo.config.forms.confirm
                          || FormSubmission.confirmMethod;
    const answer = await confirmMethod(
      confirmationMessage, this.formElement, this.submitter
    );
    if (!answer) {
      // User said "no" — abort silently
      return;
    }
  }

  // Proceed with the fetch request...
}

Step 4: Turbo Calls the Default Confirm Method

The default confirm implementation is a one-liner:

static confirmMethod(message) {
  return Promise.resolve(confirm(message));
  //                     ^^^^^^^^^^^^^^^^
  //                     This is window.confirm()
}

It wraps the synchronous window.confirm() in a Promise.resolve() so it fits Turbo's async flow.

Step 5: Chrome Silently Suppresses window.confirm()

This is where the bug lives. Chrome's anti-abuse engine determines that this page has been navigated to via history.pushState() (Step 1) rather than a full page load. After enough such navigations, Chrome's heuristic classifies subsequent confirm() calls as potential popup spam.

Chrome suppresses the dialog entirely. window.confirm() returns false immediately, without ever showing a dialog to the user.

No error is thrown. No warning is logged. The call silently returns false.

Step 6: Turbo Cancels the Submission

Turbo receives false from the confirmation method. It interprets this as "the user clicked Cancel" and aborts the form submission silently. No fetch() request is made. No server log entry is created. The user sees nothing.

Click → Turbo intercepts → Reads "data-turbo-confirm"
  → Calls window.confirm() → Chrome suppresses → returns false
  → Turbo aborts submission → Nothing happens


Understanding Turbo Drive's Form Handling

To understand why the fix works, you need to understand how Turbo handles forms.

The Turbo Lifecycle for Forms

Turbo Drive intercepts every form submission on the page (unless explicitly opted out with data-turbo="false"). The lifecycle is:

1. User clicks submit button
2. Browser fires "submit" event
3. Turbo intercepts the event (event.preventDefault())
4. IF data-turbo-confirm exists:
     → Call confirm function
     → IF returns false: STOP (abort submission)
5. Create fetch() request with form data
6. Send request to server
7. Process response:
     → 3xx redirect: Turbo navigates to the redirect URL
     → 200 with Turbo Stream: Apply stream actions
     → 4xx/5xx: Render the response in place

Where data-turbo-confirm is Read

Turbo uses a helper function that checks multiple elements in priority order:

function getAttribute(attributeName, ...elements) {
  for (const element of elements.filter(Boolean)) {
    const value = element.getAttribute(attributeName);
    if (typeof value === "string") return value;
  }
  return null;
}

// Called as:
getAttribute("data-turbo-confirm", this.submitter, this.formElement)

This means data-turbo-confirm is valid on either:

  • The submitter (<button> or <input type="submit">) — checked first
  • The form (<form>) — checked second

Both placements work. Rails' button_to with data: { turbo_confirm: "..." } places it on the button. Rails' button_to with form: { data: { turbo_confirm: "..." } } places it on the form. Neither placement fixes the Chrome suppression issue because both ultimately call window.confirm().

The Confirm Function Contract

The confirm function stored at Turbo.config.forms.confirm must satisfy this contract:

type ConfirmMethod = (
  message: string,
  formElement: HTMLFormElement,
  submitter: HTMLElement | null
) => Promise<boolean>

  • It receives the confirmation message (from data-turbo-confirm), the form element, and the submitter element
  • It must return a Promise that resolves to true (proceed) or false (cancel)
  • It is awaited — Turbo will not proceed until the promise resolves

This contract is what allows us to replace window.confirm() with any async UI — including an HTML <dialog>.


Why Chrome Suppresses window.confirm()

The Broader Context: Deprecation of Synchronous Dialogs

The window.alert(), window.confirm(), and window.prompt() functions are being systematically deprecated across all major browsers. These functions share three problematic traits:

  1. They block the main thread. When confirm() is called, all JavaScript execution stops until the user responds. In a single-threaded environment, this freezes the entire page.

  2. They are abusable. A malicious page can call alert() in an infinite loop, trapping the user. The dialogs are modal — they cannot be dismissed by closing the tab in some browsers.

  3. They cannot be styled. They use the browser's native UI, which doesn't match your application's design. Users often mistake them for system-level messages.

Chrome's Specific Suppression Rules

Chrome (and Chromium-based browsers like Edge and Brave) suppress confirm() and alert() in these scenarios:

ScenarioBehavior
Normal page loadconfirm() works normally
Cross-origin iframeconfirm() is always blocked (Chrome 92+)
After history.pushState()confirm() may be suppressed after several pushState calls
Backgrounded tabconfirm() is suppressed
Page without user activationconfirm() is suppressed

Turbo Drive navigates exclusively via fetch() + history.pushState(). Every click on a Turbo-enabled link adds a pushState call. After several such navigations (the exact threshold is heuristic and not documented), Chrome begins suppressing all synchronous dialog calls.

Why It's Intermittent

Chrome doesn't suppress confirm() on the first pushState. It uses a heuristic that considers:

  • Navigation count: More pushState calls → higher suppression probability
  • User activation: Recent clicks/keystrokes may temporarily reset the counter
  • Time intervals: Rapid navigations are more likely to trigger suppression
  • Tab focus state: Background tabs are suppressed more aggressively

This heuristic behavior is what makes the bug appear intermittent. It works on the first page load, stops working after browsing around, and works again after a hard refresh (which resets the pushState counter).

The Standards Position

The W3C and browser vendors have been clear about the direction:

"We recommend using HTML's <dialog> element or a similar widget for prompting the user, rather than using the window.prompt() method."WHATWG HTML Spec

The <dialog> element was specifically designed as the replacement for synchronous dialogs. It:

  • Cannot be suppressed — it's a DOM element rendered by the browser's layout engine
  • Is accessible — has built-in focus trapping, ARIA roles, and keyboard support
  • Is non-blocking — doesn't freeze the main thread
  • Is styleable — you control the appearance via CSS
  • Has native ::backdrop — provides a built-in overlay pseudo-element


The Complete Solution

Why <dialog> Solves It

When you call dialog.showModal():

  1. The browser renders the <dialog> as a top-layer element above everything else
  2. It creates a ::backdrop pseudo-element behind the dialog
  3. It traps keyboard focus inside the dialog
  4. It handles Escape key to close
  5. It is a DOM element, not a browser API call — Chrome's dialog suppression logic does not apply

The Annotated Implementation

import "@hotwired/turbo-rails"
import { Turbo } from "@hotwired/turbo-rails"

Turbo.config.forms.confirm = (message, formElement, submitter) => {
  // Return a Promise — Turbo will await this before proceeding.
  // resolve(true)  → Turbo submits the form
  // resolve(false) → Turbo cancels the submission
  return new Promise((resolve) => {

    // 1. Create the <dialog> element dynamically.
    //    It doesn't need to exist in the DOM ahead of time.
    const dialog = document.createElement("dialog")
    dialog.classList.add("turbo-confirm-dialog")

    // 2. Build the dialog content.
    //    The message comes from data-turbo-confirm="...".
    dialog.innerHTML = `
      <div class="confirm-content">
        <p class="confirm-message">${message}</p>
        <div class="confirm-actions">
          <button type="button" class="confirm-cancel">Cancel</button>
          <button type="button" class="confirm-ok">Confirm</button>
        </div>
      </div>
    `

    // 3. Append to <body> — required before calling showModal().
    document.body.appendChild(dialog)

    // 4. Cleanup helper: close the dialog, remove it from DOM,
    //    and resolve the promise with the user's choice.
    const cleanup = (result) => {
      dialog.close()
      dialog.remove()
      resolve(result)
    }

    // 5. Wire up button click handlers.
    dialog.querySelector(".confirm-cancel")
      .addEventListener("click", () => cleanup(false))
    dialog.querySelector(".confirm-ok")
      .addEventListener("click", () => cleanup(true))

    // 6. Clicking the backdrop (the area outside the dialog) = cancel.
    //    When <dialog> is modal, clicking the backdrop fires a click
    //    event where event.target is the <dialog> element itself.
    dialog.addEventListener("click", (e) => {
      if (e.target === dialog) cleanup(false)
    })

    // 7. Pressing Escape fires a "cancel" event on the <dialog>.
    //    We prevent the default (which would close it without resolving
    //    our promise) and handle it ourselves.
    dialog.addEventListener("cancel", (e) => {
      e.preventDefault()
      cleanup(false)
    })

    // 8. Show the dialog as a modal (with backdrop, focus trapping).
    dialog.showModal()
  })
}

Edge Cases Handled

Edge CaseHow It's Handled
User presses Escapecancel event caught → resolves with false
User clicks backdropclick event on <dialog> → resolves with false
Multiple dialogs at onceEach creates its own <dialog>, removed after use
DOM cleanupdialog.remove() called in every code path
Promise always resolvesEvery exit path calls cleanup() which calls resolve()

What About Turbo.setConfirmMethod()?

Older Turbo versions (7.x) used a different API:

// Turbo 7.x (deprecated in Turbo 8+)
Turbo.setConfirmMethod((message, element) => {
  return new Promise((resolve) => {
    // Same <dialog> logic as above
  })
})

If you're on Turbo 8+, use Turbo.config.forms.confirm. The old method still works but logs a deprecation warning:

Please replace `Turbo.setConfirmMethod(confirmMethod)` with
`Turbo.config.forms.confirm = confirmMethod`.

Checking Your Turbo Version

# Check the turbo-rails gem version
bundle show turbo-rails

# Or check via Gemfile.lock
grep turbo-rails Gemfile.lock

  • turbo-rails >= 2.0.0 → Use Turbo.config.forms.confirm
  • turbo-rails < 2.0.0 → Use Turbo.setConfirmMethod()


Reproduce From Scratch

Follow these steps to reproduce the issue in a fresh Rails application. This is useful for verifying the bug exists and testing the fix.

Prerequisites

  • Ruby 3.1+ and Rails 7.0+
  • A Chromium-based browser (Chrome, Edge, Brave)
  • Node.js (for asset pipeline)

Step 1: Create a Fresh Rails App

rails new confirm_bug_demo
cd confirm_bug_demo

Rails 7+ includes turbo-rails by default. No additional gems needed.

Step 2: Generate a Resource

rails generate scaffold Post title:string body:text
rails db:migrate

Step 3: Seed Some Data

rails runner '10.times { |i| Post.create!(title: "Post #{i+1}", body: "Body #{i+1}") }'

Step 4: Start the Server

bin/dev

Step 5: Reproduce the Bug

  1. Open Chrome and navigate to http://localhost:3000/posts
  2. Click "Destroy" on any post — the confirmation dialog should appear ✅
  3. Click "Cancel" to dismiss it
  4. Click "New Post" in the navigation (this is a Turbo Drive navigation)
  5. Click the browser back button (or any link to go back)
  6. Click a post title to view it (another Turbo navigation)
  7. Click back again to the index
  8. Repeat steps 4–7 two or three more times
  9. Now click "Destroy" again — the dialog does not appear

The more Turbo Drive navigations you make, the more reliably Chrome suppresses the confirm() call.

Step 6: Verify With a Hard Refresh

Press Ctrl+Shift+R (hard refresh). This resets Chrome's pushState counter. Click "Destroy" again — it works.

This confirms the issue is caused by Turbo Drive's history.pushState() navigations.

Step 7: Apply the Fix

Copy the JavaScript from The Fix into app/javascript/application.js and the CSS into your stylesheet. The custom <dialog> will work reliably regardless of how many Turbo navigations have occurred.


Summary

AspectDetail
Who is affectedEvery Rails 7+ application using Turbo Drive with data-turbo-confirm
Root causeChrome suppresses window.confirm() after history.pushState() navigations
SymptomConfirmation buttons do nothing — no dialog, no error, no server logs
FixOverride Turbo.config.forms.confirm with a custom <dialog> element
Effort~50 lines of JS + ~50 lines of CSS. Zero changes to views or controllers
Rails versionsRails 7.0+ (any version that includes turbo-rails)
Turbo versionsAll — the default confirm has always used window.confirm()
Browsers affectedChrome 92+, Edge (Chromium), Brave. Firefox less aggressive but trending the same way

Further Reading


Resources

0 Comments

Sign in to join the conversation

No comments yet. Be the first to comment!