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,
 	} ) => {