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(