Commit 5dab0ca21e9 for woocommerce

commit 5dab0ca21e94075c3852ff7ab4eba2e38d4572d3
Author: Andrew Matia <73502748+drewmt@users.noreply.github.com>
Date:   Thu Sep 3 08:42:22 2026 +0300

    Fix variation bulk actions racing unsaved changes (#67943)

    * Fix variation bulk action save race

    * fix: always unblock the form after saving changes

    * Address variation bulk save review feedback

    * fix: use deferred queue to avoid setTimeout hack

    * test: streamline variation bulk save e2e coverage

    ---------

    Co-authored-by: Tung Du <dinhtungdu@gmail.com>

diff --git a/plugins/woocommerce/changelog/fix-variation-bulk-save-race b/plugins/woocommerce/changelog/fix-variation-bulk-save-race
new file mode 100644
index 00000000000..eaf35f94ae6
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-variation-bulk-save-race
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent bulk variation edits from racing unsaved variation changes.
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product-variation.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product-variation.js
index 5766f88956e..d5afa834b81 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product-variation.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product-variation.js
@@ -695,9 +695,11 @@ jQuery( function ( $ ) {
 		/**
 		 * Check if have some changes before leave the page
 		 *
+		 * @param {Function} callback Called once saving is complete
+		 * @param {Function} onError  Called when saving fails
 		 * @return {Bool}
 		 */
-		check_for_changes: function () {
+		check_for_changes: function ( callback, onError ) {
 			var need_update = $( '#variable_product_options' ).find(
 				'.woocommerce_variations .variation-needs-update'
 			);
@@ -708,11 +710,16 @@ jQuery( function ( $ ) {
 						woocommerce_admin_meta_boxes_variations.i18n_edited_variations
 					)
 				) {
-					wc_meta_boxes_product_variations_ajax.save_changes();
+					wc_meta_boxes_product_variations_ajax.save_changes(
+						callback,
+						onError
+					);
 				} else {
 					need_update.removeClass( 'variation-needs-update' );
 					return false;
 				}
+			} else if ( typeof callback === 'function' ) {
+				callback();
 			}

 			return true;
@@ -833,13 +840,15 @@ jQuery( function ( $ ) {
 		 * Save variations changes
 		 *
 		 * @param {Function} callback Called once saving is complete
+		 * @param {Function} onError  Called when saving fails
 		 */
-		save_changes: function ( callback ) {
+		save_changes: function ( callback, onError ) {
 			var wrapper = $( '#variable_product_options' ).find(
 					'.woocommerce_variations'
 				),
 				need_update = $( '.variation-needs-update', wrapper ),
-				data = {};
+				data = {},
+				request;

 			// Save only with products need update.
 			if ( 0 < need_update.length ) {
@@ -855,11 +864,11 @@ jQuery( function ( $ ) {
 					woocommerce_admin_meta_boxes_variations.post_id;
 				data[ 'product-type' ] = $( '#product-type' ).val();

-				$.ajax( {
+				request = $.ajax( {
 					url: woocommerce_admin_meta_boxes_variations.ajax_url,
 					data: data,
 					type: 'POST',
-					success: function ( response ) {
+					success: function () {
 						// Allow change page, delete and add new variations
 						need_update.removeClass( 'variation-needs-update' );
 						$(
@@ -869,14 +878,20 @@ jQuery( function ( $ ) {
 						$( '#woocommerce-product-data' ).trigger(
 							'woocommerce_variations_saved'
 						);
-
-						if ( typeof callback === 'function' ) {
-							callback( response );
-						}
-
-						wc_meta_boxes_product_variations_ajax.unblock();
 					},
 				} );
+
+				request.always( function () {
+					wc_meta_boxes_product_variations_ajax.unblock();
+				} );
+
+				if ( typeof callback === 'function' ) {
+					request.done( callback );
+				}
+
+				if ( typeof onError === 'function' ) {
+					request.fail( onError );
+				}
 			}
 		},

@@ -1230,6 +1245,9 @@ jQuery( function ( $ ) {
 		do_variation_action: function () {
 			var do_variation_action = $( this ).val(),
 				data = {},
+				need_update = $( '#variable_product_options' ).find(
+					'.woocommerce_variations .variation-needs-update'
+				),
 				changes = 0,
 				value,
 				cancel = false;
@@ -1377,18 +1395,7 @@ jQuery( function ( $ ) {
 					break;
 			}

-			if ( cancel ) {
-				$( '#field_to_edit' ).val( 'bulk_actions' );
-			} else {
-				if ( 'delete_all' === do_variation_action && data.allowed ) {
-					$( '#variable_product_options' )
-						.find( '.variation-needs-update' )
-						.removeClass( 'variation-needs-update' );
-					$( '.generate_variations' ).text( 'Generate variations' );
-				} else {
-					wc_meta_boxes_product_variations_ajax.check_for_changes();
-				}
-
+			var run_bulk_action = function () {
 				wc_meta_boxes_product_variations_ajax.block();

 				$.ajax( {
@@ -1413,6 +1420,30 @@ jQuery( function ( $ ) {
 						$( '#field_to_edit' ).val( 'bulk_actions' );
 					}
 				});
+			};
+
+			if ( cancel ) {
+				$( '#field_to_edit' ).val( 'bulk_actions' );
+			} else {
+				if ( 'delete_all' === do_variation_action && data.allowed ) {
+					$( '#variable_product_options' )
+						.find( '.variation-needs-update' )
+						.removeClass( 'variation-needs-update' );
+					$( '.generate_variations' ).text(
+						woocommerce_admin_meta_boxes_variations.i18n_generate_variations
+					);
+					run_bulk_action();
+				} else if (
+					! wc_meta_boxes_product_variations_ajax.check_for_changes(
+						run_bulk_action,
+						function () {
+							$( '#field_to_edit' ).val( 'bulk_actions' );
+						}
+					)
+				) {
+					need_update.addClass( 'variation-needs-update' );
+					$( '#field_to_edit' ).val( 'bulk_actions' );
+				}
 			}
 		},

diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index d4638f503d6..0f835ea87fb 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -593,6 +593,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 					'i18n_scheduled_sale_end'             => esc_js( __( 'Sale end date (YYYY-MM-DD format or leave blank)', 'woocommerce' ) ),
 					'i18n_scheduled_sale_end_before_start' => esc_js( __( 'The sale end date cannot be earlier than the sale start date.', 'woocommerce' ) ),
 					'i18n_edited_variations'              => esc_js( __( 'Save changes before changing page?', 'woocommerce' ) ),
+					'i18n_generate_variations'            => esc_js( __( 'Generate variations', 'woocommerce' ) ),
 					'i18n_variation_count_single'         => esc_js( __( '1 variation', 'woocommerce' ) ),
 					'i18n_variation_count_plural'         => esc_js( __( '%qty% variations', 'woocommerce' ) ),
 					'i18n_variation_cost_remove_warning'  => esc_js( __( 'The custom cost of goods sold values will revert back to their defaults for all the variations. Would you like to continue?', 'woocommerce' ) ),
diff --git a/plugins/woocommerce/tests/e2e/tests/product/update-variations.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/update-variations.spec.ts
index 8a2a403cb28..87f3220cc58 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/update-variations.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/update-variations.spec.ts
@@ -31,6 +31,8 @@ const lowStockAmount = '10';

 let productId_indivEdit: number,
 	productId_bulkEdit: number,
+	productId_bulkEditAfterSave: number,
+	productId_bulkEditAfterFailedSave: number,
 	productId_deleteAll: number,
 	productId_manageStock: number,
 	productId_variationDefaults: number,
@@ -104,6 +106,26 @@ test.describe( 'Update variations', { tag: tags.GUTENBERG }, () => {
 			await createVariations( productId_bulkEdit, sampleVariations );
 		} );

+		await test.step( 'Create variable product for bulk edit after save test', async () => {
+			productId_bulkEditAfterSave =
+				await createVariableProduct( productAttributes );
+
+			await createVariations(
+				productId_bulkEditAfterSave,
+				sampleVariations
+			);
+		} );
+
+		await test.step( 'Create variable product for failed save before bulk edit test', async () => {
+			productId_bulkEditAfterFailedSave =
+				await createVariableProduct( productAttributes );
+
+			await createVariations(
+				productId_bulkEditAfterFailedSave,
+				sampleVariations
+			);
+		} );
+
 		await test.step( 'Create variable product for "delete all" test', async () => {
 			productId_deleteAll =
 				await createVariableProduct( productAttributes );
@@ -377,6 +399,163 @@ test.describe( 'Update variations', { tag: tags.GUTENBERG }, () => {
 		} );
 	} );

+	test( 'waits for unsaved variation changes before bulk editing', async ( {
+		page,
+	} ) => {
+		let saveRequestStarted = false;
+		let bulkRequestCount = 0;
+		let releaseSaveRequest: () => void = () => {};
+		const saveRequestReleased = new Promise< void >( ( resolve ) => {
+			releaseSaveRequest = resolve;
+		} );
+
+		await page.route( '**/wp-admin/admin-ajax.php', async ( route ) => {
+			const data = new URLSearchParams(
+				route.request().postData() ?? ''
+			);
+			const action = data.get( 'action' );
+
+			if ( action === 'woocommerce_save_variations' ) {
+				saveRequestStarted = true;
+				await saveRequestReleased;
+			} else if ( action === 'woocommerce_bulk_edit_variations' ) {
+				bulkRequestCount++;
+			}
+
+			await route.continue();
+		} );
+
+		await test.step( 'Go to the "Edit product" page.', async () => {
+			await page.goto(
+				`wp-admin/post.php?post=${ productId_bulkEditAfterSave }&action=edit#variable_product_options`
+			);
+		} );
+
+		await gotToVariationsTab( page );
+
+		await test.step( 'Edit a variation without saving.', async () => {
+			const firstVariation = page
+				.locator( '.woocommerce_variation' )
+				.first();
+
+			await page.getByRole( 'link', { name: 'Expand' } ).first().click();
+			await firstVariation
+				.getByRole( 'textbox', { name: 'Regular price' } )
+				.fill( variationTwoPrice );
+			await expect( firstVariation ).toHaveClass(
+				/variation-needs-update/
+			);
+		} );
+
+		await test.step( 'Start a bulk price update.', async () => {
+			page.on( 'dialog', async ( dialog ) => {
+				await dialog.accept(
+					dialog.type() === 'prompt' ? variationThreePrice : undefined
+				);
+			} );
+
+			await page
+				.locator( '#field_to_edit' )
+				.selectOption( 'variable_regular_price' );
+		} );
+
+		await test.step( 'Confirm the bulk request waits for the variation save.', async () => {
+			await expect.poll( () => saveRequestStarted ).toBe( true );
+
+			try {
+				await page.evaluate(
+					() =>
+						new Promise( ( resolve ) =>
+							requestAnimationFrame( () =>
+								requestAnimationFrame( resolve )
+							)
+						)
+				);
+				expect( bulkRequestCount ).toBe( 0 );
+			} finally {
+				releaseSaveRequest();
+			}
+
+			await expect.poll( () => bulkRequestCount ).toBe( 1 );
+			await expect( page.locator( '#field_to_edit' ) ).toHaveValue(
+				'bulk_actions'
+			);
+		} );
+	} );
+
+	test( 'does not bulk edit when saving variation changes fails', async ( {
+		page,
+	} ) => {
+		let bulkRequestCount = 0;
+
+		await page.route( '**/wp-admin/admin-ajax.php', async ( route ) => {
+			const data = new URLSearchParams(
+				route.request().postData() ?? ''
+			);
+			const action = data.get( 'action' );
+
+			if ( action === 'woocommerce_save_variations' ) {
+				await route.fulfill( {
+					status: 500,
+					contentType: 'text/plain',
+					body: 'Variation save failed',
+				} );
+				return;
+			}
+
+			if ( action === 'woocommerce_bulk_edit_variations' ) {
+				bulkRequestCount++;
+			}
+
+			await route.continue();
+		} );
+
+		await test.step( 'Go to the "Edit product" page.', async () => {
+			await page.goto(
+				`wp-admin/post.php?post=${ productId_bulkEditAfterFailedSave }&action=edit#variable_product_options`
+			);
+		} );
+
+		await gotToVariationsTab( page );
+
+		const firstVariation = page.locator( '.woocommerce_variation' ).first();
+
+		await test.step( 'Edit a variation without saving.', async () => {
+			await page.getByRole( 'link', { name: 'Expand' } ).first().click();
+			await firstVariation
+				.getByRole( 'textbox', { name: 'Regular price' } )
+				.fill( variationTwoPrice );
+			await expect( firstVariation ).toHaveClass(
+				/variation-needs-update/
+			);
+		} );
+
+		await test.step( 'Start a bulk price update.', async () => {
+			page.on( 'dialog', async ( dialog ) => {
+				await dialog.accept(
+					dialog.type() === 'prompt' ? variationThreePrice : undefined
+				);
+			} );
+
+			await page
+				.locator( '#field_to_edit' )
+				.selectOption( 'variable_regular_price' );
+		} );
+
+		await test.step( 'Keep the bulk action cancelled after the save fails.', async () => {
+			await expect( page.locator( '#field_to_edit' ) ).toHaveValue(
+				'bulk_actions'
+			);
+			await expect(
+				page.locator( '#woocommerce-product-data .blockUI' )
+			).toHaveCount( 0 );
+			await expect( firstVariation ).toHaveClass(
+				/variation-needs-update/
+			);
+			expect( bulkRequestCount ).toBe( 0 );
+		} );
+	} );
+
 	test( 'can delete all variations', async ( { page } ) => {
 		await test.step( 'Go to the "Edit product" page.', async () => {
 			await page.goto(