Commit de875ee8501 for woocommerce
commit de875ee850183db76011cc29a97f4289839b7c18
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Mon Nov 2 12:24:28 2026 +0300
Surface shipping zone save failures and keep edits made while a save is in flight (#70346)
* [WOOPLUG-8128] Surface shipping zone save failures and keep edits made mid-save
* Add changefile(s) from automation for the following project(s): woocommerce
* [WOOPLUG-8128] Drop pending method rows on save and stop faking unsaved zone changes
* [WOOPLUG-8128] Guard the save response before reading its success flag
* [WOOPLUG-8128] Keep shipping method rows blocked through an in-flight save
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/70346-fix-wooplug-8128-shipping-zone-save-failure-handling b/plugins/woocommerce/changelog/70346-fix-wooplug-8128-shipping-zone-save-failure-handling
new file mode 100644
index 00000000000..5260270a5be
--- /dev/null
+++ b/plugins/woocommerce/changelog/70346-fix-wooplug-8128-shipping-zone-save-failure-handling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the shipping zone screen getting stuck with no error when a save fails, and losing edits typed during a save.
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js b/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
index fe3f2b38b31..8c250ec8a7d 100644
--- a/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
@@ -34,8 +34,18 @@
this.trigger( 'change:methods' );
},
save: function() {
+ // Keep what this request submits, so the response can tell it apart from an edit
+ // made while the request was in flight.
+ var model = this,
+ snapshot = _.clone( this.changes ),
+ changes = _.clone( this.changes );
+
+ // Mark a save in flight: disable the button so a second save cannot start, and
+ // let render() keep the rows blocked through a mid-request zone edit.
+ this.isSaving = true;
+ $save_button.prop( 'disabled', true );
+
// Special handling for an empty 'zone_locations' array, which jQuery filters out during $.post().
- var changes = _.clone( this.changes );
if ( _.has( changes, 'zone_locations' ) && _.isEmpty( changes.zone_locations ) ) {
changes.zone_locations = [''];
}
@@ -47,34 +57,71 @@
changes : changes,
zone_id : data.zone_id
},
- this.onSaveResponse,
+ function( response, textStatus ) {
+ model.onSaveResponse( response, textStatus, snapshot );
+ },
'json'
- );
+ ).fail( function() {
+ model.onSaveFailure();
+ } );
},
- onSaveResponse: function( response, textStatus ) {
- if ( 'success' === textStatus ) {
- if ( response.success ) {
- if ( response.data.zone_id !== data.zone_id ) {
- data.zone_id = response.data.zone_id;
- if ( window.history.pushState ) {
- window.history.pushState(
- {},
- '',
- 'admin.php?page=wc-settings&tab=shipping&zone_id=' + response.data.zone_id
- );
- }
- }
- shippingMethod.set( 'methods', response.data.methods );
- shippingMethod.trigger( 'change:methods' );
- shippingMethod.changes = {};
- shippingMethod.trigger( 'saved:methods' );
+ onSaveFailure: function() {
+ shippingMethod.isSaving = false;
- // Overrides the onbeforeunload callback added by settings.js.
- window.onbeforeunload = null;
- } else {
- window.alert( data.strings.save_failed );
+ window.alert( data.strings.save_failed );
+
+ // Nothing was saved, so redraw from the model, which releases the table.
+ shippingMethod.trigger( 'rerender' );
+
+ // A row delete leaves a methods entry behind while marking the form clean, so
+ // ignore methods here exactly as the response does.
+ if ( _.isEmpty( _.omit( shippingMethod.changes, 'methods' ) ) ) {
+ // Saving an empty change set would just fail again, so clear the busy
+ // state without marking the form dirty.
+ $save_button.removeClass( 'is-busy' );
+ } else {
+ shippingMethod.trigger( 'change:methods' );
+ }
+ },
+ onSaveResponse: function( response, textStatus, snapshot ) {
+ shippingMethod.isSaving = false;
+
+ if (
+ 'success' !== textStatus ||
+ ! response ||
+ ! response.success ||
+ ! response.data
+ ) {
+ shippingMethod.onSaveFailure();
+ return;
+ }
+
+ if ( response.data.zone_id !== data.zone_id ) {
+ data.zone_id = response.data.zone_id;
+ if ( window.history.pushState ) {
+ window.history.pushState(
+ {},
+ '',
+ 'admin.php?page=wc-settings&tab=shipping&zone_id=' + response.data.zone_id
+ );
}
}
+ shippingMethod.set( 'methods', response.data.methods );
+ shippingMethod.trigger( 'change:methods' );
+
+ // Clear what this request submitted. Only the zone fields are editable while the
+ // table is blocked, so they are the only ones a mid-request edit can reach.
+ shippingMethod.changes = _.omit( shippingMethod.changes, function( value, key ) {
+ return 'methods' === key || _.isEqual( value, ( snapshot || {} )[ key ] );
+ } );
+ shippingMethod.trigger( 'saved:methods' );
+
+ if ( _.isEmpty( shippingMethod.changes ) ) {
+ // Overrides the onbeforeunload callback added by settings.js.
+ window.onbeforeunload = null;
+ } else {
+ shippingMethod.trigger( 'change:methods' );
+ }
}
} ),
@@ -195,6 +242,11 @@
}
this.initTooltips();
+
+ // render() unblocks the rows; re-apply the block if a save is still in flight.
+ if ( this.model.isSaving ) {
+ this.block();
+ }
},
initTooltips: function() {
$( '#tiptip_holder' ).removeAttr( 'style' );
@@ -263,6 +315,13 @@
},
setUnloadConfirmation: function() {
this.needsUnloadConfirm = true;
+
+ // A mid-request edit must not re-enable the button, or it could start a second,
+ // concurrent save while this one is still in flight.
+ if ( this.model.isSaving ) {
+ return;
+ }
+
$save_button.prop( 'disabled', false );
$save_button.removeClass( 'is-busy' );
},
@@ -397,7 +456,7 @@
data : shippingMethodView.unformatShippingMethodNumericValues( posted_data )
},
function( response, textStatus ) {
- if ( 'success' === textStatus && response.success ) {
+ if ( 'success' === textStatus && response && response.success && response.data ) {
$( 'table.wc-shipping-zone-methods' ).parent().find( '#woocommerce_errors' ).remove();
// If there were errors, prepend the form.
@@ -412,12 +471,13 @@
shippingMethodView.model.onSaveResponse( response, textStatus );
}
} else {
- window.alert( data.strings.save_failed );
- shippingMethodView.unblock();
+ shippingMethod.onSaveFailure();
}
},
'json'
- );
+ ).fail( function() {
+ shippingMethod.onSaveFailure();
+ } );
}
},
onConfigureShippingMethodBack: function( event, target ) {
@@ -610,26 +670,38 @@
method_id : posted_data.add_method_id,
zone_id : data.zone_id
}, function( response, textStatus ) {
- if ( 'success' === textStatus && response.success ) {
- if ( response.data.zone_id !== data.zone_id ) {
- data.zone_id = response.data.zone_id;
- if ( window.history.pushState ) {
- window.history.pushState(
- {},
- '',
- 'admin.php?page=wc-settings&tab=shipping&zone_id=' + response.data.zone_id
- );
- }
+ // An error response carries a message in place of the methods, so bail
+ // before reading them.
+ if (
+ 'success' !== textStatus ||
+ ! response ||
+ ! response.success ||
+ ! response.data
+ ) {
+ $( '#btn-next' ).removeClass( 'is-busy' );
+ shippingMethod.onSaveFailure();
+ return;
+ }
+
+ if ( response.data.zone_id !== data.zone_id ) {
+ data.zone_id = response.data.zone_id;
+ if ( window.history.pushState ) {
+ window.history.pushState(
+ {},
+ '',
+ 'admin.php?page=wc-settings&tab=shipping&zone_id=' + response.data.zone_id
+ );
}
+ }
- // Avoid triggering a rerender here because we don't want to show the method
- // in the table in case merchant doesn't finish flow.
+ // Avoid triggering a rerender here because we don't want to show the method
+ // in the table in case merchant doesn't finish flow.
- shippingMethodView.model.set( 'methods', response.data.methods );
+ shippingMethodView.model.set( 'methods', response.data.methods );
+
+ // Close original modal
+ closeModal();
- // Close original modal
- closeModal();
- }
var instance_id = response.data.instance_id,
method = response.data.methods[ instance_id ];
@@ -667,7 +739,10 @@
}
$( document.body ).trigger( 'init_tooltips' );
- }, 'json' );
+ }, 'json' ).fail( function() {
+ $( '#btn-next' ).removeClass( 'is-busy' );
+ shippingMethod.onSaveFailure();
+ } );
}
},
// Free Shipping has hidden field elements depending on data values.
diff --git a/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts b/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts
index b721db9cb4b..58323e2bd55 100644
--- a/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts
@@ -3,6 +3,7 @@
*/
import { faker } from '@faker-js/faker';
import { ApiClient, WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
+import type { Page } from '@playwright/test';
/**
* Internal dependencies
@@ -14,6 +15,47 @@ function rand() {
return faker.string.alphanumeric( 5 );
}
+const SAVE_CHANGES_ACTION = 'woocommerce_shipping_zone_methods_save_changes';
+const SAVE_SETTINGS_ACTION = 'woocommerce_shipping_zone_methods_save_settings';
+const ADD_METHOD_ACTION = 'woocommerce_shipping_zone_add_method';
+
+function isSaveChanges( url: URL ) {
+ return url.searchParams.get( 'action' ) === SAVE_CHANGES_ACTION;
+}
+
+async function openAddMethodModal( page: Page ) {
+ // The edit screen binds the add-method modal after its methods table renders, so retry
+ // opening it in case the first click lands before the handler is bound.
+ const addMethodButton = page.getByRole( 'button', {
+ name: 'Add shipping method',
+ } );
+ const createMethodHeading = page.getByRole( 'heading', {
+ name: 'Create shipping method',
+ } );
+
+ await expect( async () => {
+ if ( ! ( await createMethodHeading.isVisible() ) ) {
+ await addMethodButton.click();
+ }
+ await expect( createMethodHeading ).toBeVisible( { timeout: 3000 } );
+ } ).toPass( { timeout: 20000 } );
+}
+
+async function openZone( page: Page, zoneName: string ) {
+ await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=shipping' );
+ await page
+ .getByText( zoneName )
+ .locator(
+ '~ td.wc-shipping-zone-actions a.wc-shipping-zone-action-edit'
+ )
+ .click();
+ await expect(
+ page.locator( '.wc-shipping-zone-method-title', {
+ hasText: 'Flat rate',
+ } )
+ ).toBeVisible();
+}
+
async function deleteZoneById( restApi: ApiClient, zoneId?: number ) {
if ( zoneId === undefined ) {
return;
@@ -294,22 +336,7 @@ test( 'persists pending zone edits when adding a non-modal method to an existing
} ) => {
const renamedZone = `Renamed zone ${ rand() }`;
- await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=shipping' );
- await page
- .getByText( zone.name )
- .locator(
- '~ td.wc-shipping-zone-actions a.wc-shipping-zone-action-edit'
- )
- .click();
-
- // The edit screen binds the add-method modal after its methods table renders, so
- // wait for the existing method, then retry opening the modal in case the first
- // click lands before the handler is bound.
- await expect(
- page.locator( '.wc-shipping-zone-method-title', {
- hasText: 'Flat rate',
- } )
- ).toBeVisible();
+ await openZone( page, zone.name );
// Register before the edit so an unexpected onbeforeunload prompt is caught.
const dialogMessages: string[] = [];
@@ -322,18 +349,7 @@ test( 'persists pending zone edits when adding a non-modal method to an existing
await page.getByLabel( 'Zone name' ).fill( renamedZone );
await expect( page.locator( '#submit' ) ).toBeEnabled();
- const addMethodButton = page.getByRole( 'button', {
- name: 'Add shipping method',
- } );
- const createMethodHeading = page.getByRole( 'heading', {
- name: 'Create shipping method',
- } );
- await expect( async () => {
- if ( ! ( await createMethodHeading.isVisible() ) ) {
- await addMethodButton.click();
- }
- await expect( createMethodHeading ).toBeVisible( { timeout: 3000 } );
- } ).toPass( { timeout: 20000 } );
+ await openAddMethodModal( page );
await page.getByText( 'E2E Non-Modal Method' ).click();
const addMethodResponsePromise = page.waitForResponse( ( response ) =>
@@ -386,3 +402,277 @@ test( 'persists pending zone edits when adding a non-modal method to an existing
);
expect( savedZone.data.name ).toBe( renamedZone );
} );
+
+// Regression: a failed zone save must surface the error and release the methods table.
+// The old flow left the table under the blocking overlay with no message at all.
+test( 'surfaces a failed zone save and releases the methods table', async ( {
+ page,
+ zone,
+} ) => {
+ await openZone( page, zone.name );
+
+ const dialogMessages: string[] = [];
+ page.on( 'dialog', async ( dialog ) => {
+ dialogMessages.push( dialog.message() );
+ await dialog.accept();
+ } );
+
+ await page.route( isSaveChanges, ( route ) =>
+ route.fulfill( {
+ status: 500,
+ contentType: 'text/plain',
+ body: 'Injected save failure',
+ } )
+ );
+
+ await page.getByLabel( 'Zone name' ).fill( `Failed save ${ rand() }` );
+ await expect( page.locator( '#submit' ) ).toBeEnabled();
+ await page.locator( '#submit' ).click();
+
+ await expect
+ .poll( () => dialogMessages )
+ .toContain( 'Your changes were not saved. Please retry.' );
+
+ // Releasing the table and clearing the busy state are what regress; the enabled button
+ // is recorded as the expected end state rather than as coverage, since Save changes is
+ // never disabled on this path either way.
+ await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+ await expect( page.locator( '#submit' ) ).toBeEnabled();
+ await expect( page.locator( '#submit' ) ).not.toHaveClass( /is-busy/ );
+ expect( dialogMessages ).toHaveLength( 1 );
+} );
+
+// Regression: an edit typed while a save is in flight must stay unsaved instead of being
+// cleared when that response lands, so the next save still carries it.
+test( 'keeps a zone edit made while an earlier save is in flight', async ( {
+ page,
+ restApi,
+ zone,
+} ) => {
+ const firstName = `First save ${ rand() }`;
+ const secondName = `Typed during save ${ rand() }`;
+
+ await openZone( page, zone.name );
+
+ let releaseFirstSave = () => {};
+ const firstSaveHeld = new Promise< void >( ( resolve ) => {
+ releaseFirstSave = resolve;
+ } );
+ let alreadyHeld = false;
+ await page.route( isSaveChanges, async ( route ) => {
+ if ( ! alreadyHeld ) {
+ alreadyHeld = true;
+ await firstSaveHeld;
+ }
+ await route.continue();
+ } );
+
+ // jQuery fires ajaxComplete after the request's success callback, so this flag is the only
+ // barrier that proves the save response was handled. Editing the zone name re-renders and
+ // unblocks the table on its own, so the overlay clearing proves nothing here.
+ await page.evaluate( ( action: string ) => {
+ const context = window as unknown as {
+ wcSaveHandled?: boolean;
+ jQuery: ( target: Document ) => {
+ on: (
+ event: string,
+ handler: ( ...args: unknown[] ) => void
+ ) => void;
+ };
+ };
+
+ context.wcSaveHandled = false;
+ context.jQuery( document ).on( 'ajaxComplete', ( ...args ) => {
+ const settings = args[ 2 ] as { url?: string } | undefined;
+ if ( settings && settings.url && settings.url.includes( action ) ) {
+ context.wcSaveHandled = true;
+ }
+ } );
+ }, SAVE_CHANGES_ACTION );
+
+ await page.getByLabel( 'Zone name' ).fill( firstName );
+ await page.locator( '#submit' ).click();
+
+ // The zone fields stay editable while the table is blocked, so type again mid-request.
+ await page.getByLabel( 'Zone name' ).fill( secondName );
+ releaseFirstSave();
+ await page.waitForFunction(
+ () =>
+ ( window as unknown as { wcSaveHandled?: boolean } )
+ .wcSaveHandled === true
+ );
+
+ await expect( page.locator( '#submit' ) ).toBeEnabled();
+ await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+ await expect( page.getByLabel( 'Zone name' ) ).toHaveValue( secondName );
+
+ const secondSave = page.waitForResponse( ( response ) =>
+ isSaveChanges( new URL( response.url() ) )
+ );
+ await page.locator( '#submit' ).click();
+ await secondSave;
+ await expect( page.locator( '#submit' ) ).toBeDisabled();
+
+ const saved = await restApi.get(
+ `${ WC_API_PATH }/shipping/zones/${ zone.id }`
+ );
+ expect( saved.data.name ).toBe( secondName );
+} );
+
+// Regression: a failed method-settings save must surface the error and release the table without
+// pretending the zone has unsaved changes, which would offer a retry that cannot succeed.
+const settingsSaveFailures = [
+ {
+ label: 'a transport failure',
+ response: {
+ status: 500,
+ contentType: 'text/plain',
+ body: 'Injected settings save failure',
+ },
+ },
+ {
+ label: 'an error response',
+ response: {
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify( { success: false, data: 'bad_nonce' } ),
+ },
+ },
+];
+
+for ( const { label, response } of settingsSaveFailures ) {
+ test( `surfaces ${ label } when saving method settings, without faking unsaved zone changes`, async ( {
+ page,
+ zone,
+ } ) => {
+ await openZone( page, zone.name );
+
+ const dialogMessages: string[] = [];
+ page.on( 'dialog', async ( dialog ) => {
+ dialogMessages.push( dialog.message() );
+ await dialog.accept();
+ } );
+
+ await page.route(
+ ( url ) =>
+ url.searchParams.get( 'action' ) === SAVE_SETTINGS_ACTION,
+ ( route ) => route.fulfill( response )
+ );
+
+ await page
+ .locator( 'a.wc-shipping-zone-method-settings' )
+ .first()
+ .click();
+ await page.locator( '#btn-ok' ).click();
+
+ await expect
+ .poll( () => dialogMessages )
+ .toContain( 'Your changes were not saved. Please retry.' );
+
+ await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+ // The zone itself was never edited, so it must not look dirty.
+ await expect( page.locator( '#submit' ) ).toBeDisabled();
+ await expect( page.locator( '#submit' ) ).not.toHaveClass( /is-busy/ );
+ expect( dialogMessages ).toHaveLength( 1 );
+ } );
+}
+
+// Regression: wp_send_json_error answers with HTTP 200, so the add-method response has to be
+// checked before its methods are read. Reading them threw, which skipped every cleanup.
+test( 'recovers when adding a shipping method is rejected with an error response', async ( {
+ page,
+ zone,
+} ) => {
+ await openZone( page, zone.name );
+
+ const dialogMessages: string[] = [];
+ page.on( 'dialog', async ( dialog ) => {
+ dialogMessages.push( dialog.message() );
+ await dialog.accept();
+ } );
+
+ await page.route(
+ ( url ) => url.searchParams.get( 'action' ) === ADD_METHOD_ACTION,
+ ( route ) =>
+ route.fulfill( {
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify( { success: false, data: 'bad_nonce' } ),
+ } )
+ );
+
+ await openAddMethodModal( page );
+ await page.getByText( 'Free shipping', { exact: true } ).click();
+ await page.getByRole( 'button', { name: 'Continue' } ).click();
+
+ await expect
+ .poll( () => dialogMessages )
+ .toContain( 'Your changes were not saved. Please retry.' );
+
+ await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+ await expect( page.locator( '#btn-next' ) ).not.toHaveClass( /is-busy/ );
+ expect( dialogMessages ).toHaveLength( 1 );
+} );
+
+// Regression: deleting a row leaves an entry in the pending set while correctly marking the
+// form clean, so a later failure must not read that as unsaved zone work and light Save changes.
+test( 'does not claim unsaved zone changes after a row delete and a failed settings save', async ( {
+ page,
+ restApi,
+ zone,
+} ) => {
+ // The delete only leaves that entry behind when another method remains.
+ await restApi.post(
+ `${ WC_API_PATH }/shipping/zones/${ zone.id }/methods`,
+ {
+ method_id: 'free_shipping',
+ }
+ );
+
+ await openZone( page, zone.name );
+
+ const dialogMessages: string[] = [];
+ page.on( 'dialog', async ( dialog ) => {
+ dialogMessages.push( dialog.message() );
+ await dialog.accept();
+ } );
+
+ await page
+ .getByRole( 'cell', { name: 'Edit | Delete', exact: true } )
+ .first()
+ .locator( 'text=Delete' )
+ .click();
+ await expect(
+ page.locator(
+ '.wc-shipping-zone-method-rows .wc-shipping-zone-method-title'
+ )
+ ).toHaveCount( 1 );
+ await expect( page.locator( '#submit' ) ).toBeDisabled();
+
+ await page.route(
+ ( url ) => url.searchParams.get( 'action' ) === SAVE_SETTINGS_ACTION,
+ ( route ) =>
+ route.fulfill( {
+ status: 500,
+ contentType: 'text/plain',
+ body: 'Injected settings save failure',
+ } )
+ );
+
+ await page.locator( 'a.wc-shipping-zone-method-settings' ).first().click();
+ await page.locator( '#btn-ok' ).click();
+
+ await expect
+ .poll( () => dialogMessages )
+ .toContain( 'Your changes were not saved. Please retry.' );
+
+ // Nothing about the zone is pending, so the screen must not claim otherwise.
+ await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+ await expect( page.locator( '#submit' ) ).toBeDisabled();
+ expect(
+ dialogMessages.filter(
+ ( message ) =>
+ message === 'Your changes were not saved. Please retry.'
+ )
+ ).toHaveLength( 1 );
+} );