From 972a6f851e4a5a4b955a4e5bf4b3a15c7aa95d24 Mon Sep 17 00:00:00 2001 From: Akira Matsuda Date: Sat, 11 Jul 2026 12:51:15 +0900 Subject: [PATCH] Prevent double proposal submission from a double-clicked submit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This app disables Turbo Drive globally (opt-in), so the proposal form submits as a plain full-page POST with no double-submit protection — Turbo's submitter disabling never engages and rails-ujs disable_with is no longer bundled. A double click during a slow response POSTed twice and created duplicate proposals. Add a small generic disable-submit Stimulus controller that disables the form's submit buttons on the next tick after the submit event (so the form data set, including a named submitter, is built first), and wire it to the proposal new/edit forms. Co-Authored-By: Claude Fable 5 --- .../controllers/disable_submit_controller.js | 24 ++++++++++ app/javascript/controllers/index.js | 2 + app/views/proposals/edit.html.haml | 2 +- app/views/proposals/new.html.haml | 2 +- spec/system/proposal_double_submit_spec.rb | 48 +++++++++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 app/javascript/controllers/disable_submit_controller.js create mode 100644 spec/system/proposal_double_submit_spec.rb diff --git a/app/javascript/controllers/disable_submit_controller.js b/app/javascript/controllers/disable_submit_controller.js new file mode 100644 index 000000000..0f5a535bc --- /dev/null +++ b/app/javascript/controllers/disable_submit_controller.js @@ -0,0 +1,24 @@ +import { Controller } from '@hotwired/stimulus' + +// Disables a form's submit buttons once it is submitted, so a double click +// cannot POST twice. Turbo Drive does this for opted-in forms, but this app +// disables Drive globally (application.js), so plain full-page forms get no +// protection from Turbo and rails-ujs (disable_with) is no longer bundled. +// +// Usage: form html: {data: {controller: 'disable-submit', action: 'submit->disable-submit#disable'}} +export default class extends Controller { + disable(event) { + // A handler registered before this one may have canceled the submission + // (e.g. client-side validation) — leave the button usable then. + if (event.defaultPrevented) return + + // Disable on the next tick: the browser builds the form data set after + // the submit event finishes, and a button disabled too early would have + // its own name/value dropped from the submission. + setTimeout(() => { + this.element.querySelectorAll('button[type="submit"], input[type="submit"]').forEach(button => { + button.disabled = true + }) + }) + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 4407fa7f4..8b2351d5a 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -13,6 +13,7 @@ import AlertAutodismissController from "./alert_autodismiss_controller" import AlertController from "./alert_controller" import BannerAdsController from "./banner_ads_controller" import CardDisplayToggleController from "./card_display_toggle_controller" +import DisableSubmitController from "./disable_submit_controller" import GridModalController from "./grid_modal_controller" import CfpDatatableController from "./cfp_datatable_controller" import ContentController from "./content_controller" @@ -53,6 +54,7 @@ application.register("alert-autodismiss", AlertAutodismissController) application.register("alert", AlertController) application.register("banner-ads", BannerAdsController) application.register("card-display-toggle", CardDisplayToggleController) +application.register("disable-submit", DisableSubmitController) application.register("grid-modal", GridModalController) application.register("cfp-datatable", CfpDatatableController) application.register("content", ContentController) diff --git a/app/views/proposals/edit.html.haml b/app/views/proposals/edit.html.haml index cbdf85aa8..13d229e42 100644 --- a/app/views/proposals/edit.html.haml +++ b/app/views/proposals/edit.html.haml @@ -28,7 +28,7 @@ %a{href: 'https://help.github.com/articles/github-flavored-markdown'} %strong GitHub Flavored Markdown. - = simple_form_for [event, proposal], url: event_proposal_path(event.slug, proposal), html: {'data-proposal-preview-target' => 'form'} do |f| + = simple_form_for [event, proposal], url: event_proposal_path(event.slug, proposal), html: {data: {proposal_preview_target: 'form', controller: 'disable-submit', action: 'submit->disable-submit#disable'}} do |f| = render partial: 'form', locals: {f: f} #preview.tab-pane = render partial: 'preview', locals: { proposal: proposal } diff --git a/app/views/proposals/new.html.haml b/app/views/proposals/new.html.haml index 048b67961..20a8755da 100644 --- a/app/views/proposals/new.html.haml +++ b/app/views/proposals/new.html.haml @@ -26,7 +26,7 @@ %a{href: 'https://help.github.com/articles/github-flavored-markdown'} %strong GitHub Flavored Markdown. - = simple_form_for proposal, url: event_proposals_path(event), html: {'data-proposal-preview-target' => 'form'} do |f| + = simple_form_for proposal, url: event_proposals_path(event), html: {data: {proposal_preview_target: 'form', controller: 'disable-submit', action: 'submit->disable-submit#disable'}} do |f| = render partial: 'form', locals: {f: f} #preview.tab-pane = render partial: 'preview', locals: { proposal: proposal } diff --git a/spec/system/proposal_double_submit_spec.rb b/spec/system/proposal_double_submit_spec.rb new file mode 100644 index 000000000..2453023c1 --- /dev/null +++ b/spec/system/proposal_double_submit_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +feature 'Proposal submission double-click protection', type: :system, js: true do + let!(:user) { create(:user) } + let!(:event) { create(:event, state: 'open') } + let!(:session_format) { create(:session_format, name: 'Only format') } + + before do + login_as(user) + visit new_event_proposal_path(event) + + fill_in 'Title', with: 'Once and only once' + fill_in 'Abstract', with: 'A talk about idempotent form submissions.' + fill_in 'proposal_speakers_attributes_0_bio', with: 'I am awesome.' + fill_in 'Pitch', with: 'Delivered exactly once.' + fill_in 'Details', with: 'No double submissions.' + select 'Only format', from: 'Session format' + end + + scenario 'the submit button is disabled as soon as the form is submitted' do + # Keep the page around after submit so the button state can be inspected: + # this listener runs after the disable-submit controller's (registered at + # connect time), so the disable still happens first. + page.execute_script(<<~JS) + document.querySelector('[data-proposal-preview-target="form"]') + .addEventListener('submit', function(e) { e.preventDefault() }) + JS + click_button 'Submit' + + expect(page).to have_button('Submit', disabled: true) + expect(Proposal.count).to eq(0) + end + + scenario 'a double click creates exactly one proposal' do + button = find('.form-submit button[type="submit"]') + button.click + # The second click of a double-click either hits an already-disabled + # button or a page mid-navigation; either way one proposal must result. + begin + button.click + rescue StandardError + # element detached mid-navigation — the first submit won, which is fine + end + + expect(page).to have_content('Thank you!') + expect(Proposal.where(title: 'Once and only once').count).to eq(1) + end +end