Commit 14bd094325d for woocommerce
commit 14bd094325d7ec602a282ef1aaae7fcc4fb70660
Author: Mike Jolley <mike.jolley@me.com>
Date: Tue Oct 20 12:36:25 2026 +0100
Prevent a double-click on Update from submitting the order twice (#69817)
* Prevent duplicate order form submissions from a double-click on Update
* Add changelog entry for order edit double-submit fix
* Fix order double-submit e2e test when HPOS is disabled
* Unlock the order form when a later handler cancels the submission
* Match order save URLs without a regex in the double-submit test
* Clarify order double-submit guard comments
diff --git a/plugins/woocommerce/changelog/30759-prevent-order-edit-double-submit b/plugins/woocommerce/changelog/30759-prevent-order-edit-double-submit
new file mode 100644
index 00000000000..8a259105d66
--- /dev/null
+++ b/plugins/woocommerce/changelog/30759-prevent-order-edit-double-submit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop a double-click on Update in the order editor from running order actions, such as resending emails, twice.
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
index 81e2f0afcce..0d6d7daa515 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
@@ -34,6 +34,78 @@ jQuery( function ( $ ) {
$( 'a.load_customer_billing' ).on( 'click', this.load_billing );
$( 'a.load_customer_shipping' ).on( 'click', this.load_shipping );
$( '#customer_user' ).on( 'change', this.change_customer_user );
+ // The order edit form is form#order with HPOS and form#post with posts storage.
+ $( document.body ).on(
+ 'submit',
+ 'form#order, form#post',
+ this.prevent_double_submit
+ );
+ $( window ).on( 'pageshow', this.reset_double_submit );
+ },
+
+ /**
+ * Ignore repeat submissions of the order form, e.g. from a double-click on Update. The server
+ * runs the selected order action on every submission it receives, so only the first may go through.
+ *
+ * Bound to the body so handlers on the form itself run first and can still cancel the submission.
+ */
+ prevent_double_submit: function ( e ) {
+ var $form = $( this ),
+ $buttons;
+
+ if ( $form.data( 'wc-order-submitting' ) ) {
+ e.preventDefault();
+ return;
+ }
+
+ if ( e.isDefaultPrevented() ) {
+ return;
+ }
+
+ // Skip buttons that are already disabled, so unlocking leaves them as they were.
+ $buttons = $form
+ .find( '#woocommerce-order-actions :submit' )
+ .not( '.disabled, [aria-disabled="true"]' )
+ .addClass( 'disabled' )
+ .attr( 'aria-disabled', 'true' );
+ $form.data( 'wc-order-submitting', $buttons );
+
+ // Handlers further up the page run after this one and can still cancel the submission.
+ setTimeout( function () {
+ if (
+ e.isDefaultPrevented() ||
+ ( e.originalEvent && e.originalEvent.defaultPrevented )
+ ) {
+ wc_meta_boxes_order.unlock_order_form( $form );
+ }
+ } );
+ },
+
+ /**
+ * Unlock the order form when the browser restores the page from its back/forward cache.
+ */
+ reset_double_submit: function ( e ) {
+ if ( ! e.originalEvent.persisted ) {
+ return;
+ }
+
+ $( 'form#order, form#post' ).each( function () {
+ wc_meta_boxes_order.unlock_order_form( $( this ) );
+ } );
+ },
+
+ /**
+ * Restore the Order actions buttons that were disabled when the form was locked, and clear the lock.
+ */
+ unlock_order_form: function ( $form ) {
+ var $buttons = $form.data( 'wc-order-submitting' );
+
+ if ( $buttons ) {
+ $buttons
+ .removeClass( 'disabled' )
+ .removeAttr( 'aria-disabled' );
+ $form.removeData( 'wc-order-submitting' );
+ }
},
change_country: function( e, stickValue ) {
diff --git a/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
index 58d5dd90c28..fafd3fcb037 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+import type { Request } from '@playwright/test';
import { ApiClient, WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
/**
@@ -14,10 +15,19 @@ import { wpCLI } from '../../utils/cli';
test.use( { storageState: ADMIN_STATE_PATH } );
+const isOrderFormPost = ( request: Request ) =>
+ request.method() === 'POST' && request.resourceType() === 'document';
+
+// The order form saves to admin.php with HPOS and post.php with posts storage.
+const isOrderSaveUrl = ( url: URL ) =>
+ url.pathname.endsWith( '/wp-admin/admin.php' ) ||
+ url.pathname.endsWith( '/wp-admin/post.php' );
+
test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
let orderId: number,
secondOrderId: number,
orderToCancel: number,
+ doubleClickOrderId: number,
customerId: number;
const username = `big.archie.${ Date.now() }`;
@@ -43,6 +53,14 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
.then( ( response: { data: { id: number } } ) => {
orderToCancel = response.data.id;
} );
+ await restApi
+ .post( `${ WC_API_PATH }/orders`, {
+ status: 'processing',
+ billing: { email: `${ username }@email.addr` },
+ } )
+ .then( ( response: { data: { id: number } } ) => {
+ doubleClickOrderId = response.data.id;
+ } );
await restApi
.post( `${ WC_API_PATH }/customers`, {
email: `${ username }@email.addr`,
@@ -89,6 +107,10 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
await restApi.delete( `${ WC_API_PATH }/orders/${ orderToCancel }`, {
force: true,
} );
+ await restApi.delete(
+ `${ WC_API_PATH }/orders/${ doubleClickOrderId }`,
+ { force: true }
+ );
await restApi.delete( `${ WC_API_PATH }/customers/${ customerId }`, {
force: true,
} );
@@ -197,6 +219,54 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
);
} );
+ test( 'double-clicking Update only submits the order once', async ( {
+ page,
+ } ) => {
+ await page.goto(
+ `wp-admin/admin.php?page=wc-orders&action=edit&id=${ doubleClickOrderId }`
+ );
+
+ const requests: Request[] = [];
+ page.on( 'request', ( request ) => requests.push( request ) );
+
+ await page
+ .locator( 'select[name="wc_order_action"]' )
+ .selectOption( 'send_order_details' );
+
+ // Hold the save request so the second click lands on the same page, like a double-click on a slow site.
+ let releaseSave = () => {};
+ const saveReleased = new Promise< void >( ( resolve ) => {
+ releaseSave = resolve;
+ } );
+ await page.route( isOrderSaveUrl, async ( route ) => {
+ await saveReleased;
+ await route.continue().catch( () => undefined );
+ } );
+
+ // Mouse clicks don't wait for the pending navigation, unlike locator clicks.
+ const updateButton = ( await page
+ .locator( 'button.save_order' )
+ .boundingBox() )!;
+ const x = updateButton.x + updateButton.width / 2;
+ const y = updateButton.y + updateButton.height / 2;
+ const firstPost = page.waitForRequest( isOrderFormPost );
+ await page.mouse.click( x, y );
+ await firstPost;
+ await page.mouse.click( x, y );
+ releaseSave();
+
+ await expect(
+ page
+ .locator( 'div.notice-success > p' )
+ .filter( { hasText: /^Order updated/ } )
+ ).toBeVisible();
+ const orderDetailsSentNotes = page
+ .locator( '#woocommerce-order-notes .note_content' )
+ .filter( { hasText: 'Order details manually sent to customer.' } );
+ await expect( orderDetailsSentNotes ).toHaveCount( 1 );
+ expect( requests.filter( isOrderFormPost ) ).toHaveLength( 1 );
+ } );
+
test( 'saving an order does not trigger a false unsaved-changes warning', async ( {
page,
} ) => {