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 );
+} );