Commit 4b6278b7a90 for woocommerce

commit 4b6278b7a906da3b102c39b8a70a04ef5233d056
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Wed Oct 28 18:19:21 2026 +0300

    Save a new shipping zone's name and region when adding a non-modal method (#70002)

    * Save a new shipping zone's name and region when adding a non-modal method

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Stabilize the existing-zone non-modal shipping method e2e test

    * Reword the non-modal shipping method save comment

    * [WOOPLUG-3626] Cover pending zone edits when adding a non-modal method

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/70002-fix-wooplug-3626-shipping-zone-nonmodal-save b/plugins/woocommerce/changelog/70002-fix-wooplug-3626-shipping-zone-nonmodal-save
new file mode 100644
index 00000000000..072a43b0b41
--- /dev/null
+++ b/plugins/woocommerce/changelog/70002-fix-wooplug-3626-shipping-zone-nonmodal-save
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep a new shipping zone's name and regions when the first method added has settings but no settings modal.
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 466d92d643a..fe3f2b38b31 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
@@ -655,8 +655,15 @@

 								shippingMethodView.highlightOnFocus( '.wc-shipping-modal-price' );
 							} else {
-								shippingMethodView.model.trigger( 'change:methods' );
-								shippingMethodView.model.trigger( 'saved:methods' );
+								// Non-modal methods are already added server-side; flush any pending zone edits and
+								// re-render, mirroring the modal settings-save path so new zones keep their locations.
+								if ( _.size( shippingMethodView.model.changes ) ) {
+									// Block the methods table across the async save, as the modal path does.
+									shippingMethodView.block();
+									shippingMethodView.model.save();
+								} else {
+									shippingMethodView.model.onSaveResponse( response, textStatus );
+								}
 							}

 							$( document.body ).trigger( 'init_tooltips' );
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php b/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
index 085fe79d6f4..0ef0b94b0eb 100644
--- a/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
+++ b/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
@@ -359,3 +359,75 @@ add_filter(
 	'__return_zero',
 	PHP_INT_MAX
 );
+
+/*
+ * -----------------------------------------------------------------------------
+ * Shipping methods
+ * -----------------------------------------------------------------------------
+ *
+ * Registers a zone shipping method that supports instance settings but deliberately
+ * not 'instance-settings-modal', like UPS/USPS. Core ships no such method, so the
+ * shipping-zone save flow for non-modal methods has no coverage without one.
+ */
+add_action(
+	'woocommerce_shipping_init',
+	function () {
+		if ( class_exists( 'WC_E2E_Nonmodal_Shipping_Method' ) ) {
+			return;
+		}
+
+		/**
+		 * A minimal instance-based shipping method that omits 'instance-settings-modal'
+		 * so it configures on a full settings page instead of the zones modal.
+		 */
+		class WC_E2E_Nonmodal_Shipping_Method extends WC_Shipping_Method {
+			/**
+			 * @param int $instance_id Instance id.
+			 */
+			public function __construct( $instance_id = 0 ) {
+				$this->id                 = 'e2e_nonmodal';
+				$this->instance_id        = absint( $instance_id );
+				$this->method_title       = 'E2E Non-Modal Method';
+				$this->method_description = 'Instance settings without a modal, for save-flow testing.';
+				$this->supports           = array( 'shipping-zones', 'instance-settings' );
+				$this->init();
+			}
+
+			/**
+			 * Load settings and hook the save handler.
+			 */
+			public function init() {
+				$this->instance_form_fields = array(
+					'title' => array(
+						'title'   => 'Title',
+						'type'    => 'text',
+						'default' => 'E2E Non-Modal Method',
+					),
+				);
+				$this->title                = $this->get_option( 'title' );
+				add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'process_admin_options' ) );
+			}
+
+			/**
+			 * @param array $package Shipping package.
+			 */
+			public function calculate_shipping( $package = array() ) {
+				$this->add_rate(
+					array(
+						'id'    => $this->id . $this->instance_id,
+						'label' => $this->title,
+						'cost'  => 0,
+					)
+				);
+			}
+		}
+	}
+);
+
+add_filter(
+	'woocommerce_shipping_methods',
+	function ( $methods ) {
+		$methods['e2e_nonmodal'] = 'WC_E2E_Nonmodal_Shipping_Method';
+		return $methods;
+	}
+);
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 d3f40d47c34..b721db9cb4b 100644
--- a/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts
@@ -191,3 +191,198 @@ test( 'saves an unsaved shipping zone when adding a method', async ( {
 		await deleteZoneById( restApi, createdZoneId );
 	}
 } );
+
+// Regression: a method that has instance settings but no settings modal (UPS/USPS
+// style; here the "E2E Non-Modal Method" registered by the E2E test helper) must still
+// mark the form dirty and persist the new zone's name and region. The old flow lost both,
+// so the zone silently reverted to "Everywhere" on reload.
+test( 'saves an unsaved shipping zone when adding a non-modal method', async ( {
+	page,
+	restApi,
+} ) => {
+	const zoneName = `Unsaved non-modal zone ${ rand() }`;
+	let createdZoneId: number | undefined;
+
+	try {
+		await page.goto(
+			'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new'
+		);
+
+		// Register before any interaction so an unexpected onbeforeunload prompt is caught.
+		const dialogMessages: string[] = [];
+		page.on( 'dialog', async ( dialog ) => {
+			dialogMessages.push( dialog.message() );
+			await dialog.accept();
+		} );
+
+		await page.getByLabel( 'Zone name' ).fill( zoneName );
+		await page
+			.getByRole( 'combobox', { name: 'Start typing to filter zones' } )
+			.fill( 'United States' );
+		await page
+			.getByRole( 'checkbox', {
+				name: 'United States (US)',
+				exact: true,
+			} )
+			.click();
+		await page.keyboard.press( 'Escape' );
+		await expect( page.locator( '.select2-container--open' ) ).toBeHidden();
+
+		await expect( page.locator( '#submit' ) ).toBeEnabled();
+
+		await page
+			.getByRole( 'button', { name: 'Add shipping method' } )
+			.click();
+		await page.getByText( 'E2E Non-Modal Method' ).click();
+
+		const addMethodResponsePromise = page.waitForResponse( ( response ) =>
+			response
+				.url()
+				.includes( 'action=woocommerce_shipping_zone_add_method' )
+		);
+		const saveChangesResponsePromise = page.waitForResponse( ( response ) =>
+			response
+				.url()
+				.includes(
+					'action=woocommerce_shipping_zone_methods_save_changes'
+				)
+		);
+		await page.getByRole( 'button', { name: 'Continue' } ).click();
+
+		const addMethodResponse = await addMethodResponsePromise;
+		createdZoneId = ( await addMethodResponse.json() ).data.zone_id;
+		// A non-modal method has no settings modal, so the add itself must flush the
+		// pending zone edits via save_changes.
+		await saveChangesResponsePromise;
+
+		await expect(
+			page.locator( '.wc-shipping-zone-method-title', {
+				hasText: 'E2E Non-Modal Method',
+			} )
+		).toBeVisible();
+		await expect( page.locator( '#submit' ) ).toBeDisabled();
+		await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+
+		await page.reload();
+		await expect( page.getByLabel( 'Zone name' ) ).toHaveValue( zoneName );
+		await expect(
+			page.locator( '.wc-shipping-zone-method-title', {
+				hasText: 'E2E Non-Modal Method',
+			} )
+		).toBeVisible();
+		expect( dialogMessages ).toEqual( [] );
+
+		// The region must survive instead of reverting to "Everywhere".
+		const locations = await restApi.get(
+			`${ WC_API_PATH }/shipping/zones/${ createdZoneId }/locations`
+		);
+		expect( locations.data ).toContainEqual(
+			expect.objectContaining( { code: 'US', type: 'country' } )
+		);
+	} finally {
+		await deleteZoneById( restApi, createdZoneId );
+	}
+} );
+
+// Regression: adding a non-modal method to an existing zone must flush the zone's
+// pending edits and re-render without dropping its other methods. The old flow kept
+// the unsaved name only in the browser, so it was lost on the next load.
+test( 'persists pending zone edits when adding a non-modal method to an existing zone', async ( {
+	page,
+	restApi,
+	zone,
+} ) => {
+	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();
+
+	// Register before the edit so an unexpected onbeforeunload prompt is caught.
+	const dialogMessages: string[] = [];
+	page.on( 'dialog', async ( dialog ) => {
+		dialogMessages.push( dialog.message() );
+		await dialog.accept();
+	} );
+
+	// Leave the rename unsaved, so adding the method is what has to persist it.
+	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 page.getByText( 'E2E Non-Modal Method' ).click();
+
+	const addMethodResponsePromise = page.waitForResponse( ( response ) =>
+		response.url().includes( 'action=woocommerce_shipping_zone_add_method' )
+	);
+	const saveChangesResponsePromise = page.waitForResponse( ( response ) =>
+		response
+			.url()
+			.includes( 'action=woocommerce_shipping_zone_methods_save_changes' )
+	);
+	await page.getByRole( 'button', { name: 'Continue' } ).click();
+	await addMethodResponsePromise;
+	// A non-modal method has no settings modal, so the add itself must flush the
+	// pending zone edits via save_changes.
+	await saveChangesResponsePromise;
+
+	await expect(
+		page.locator( '.wc-shipping-zone-method-title', {
+			hasText: 'E2E Non-Modal Method',
+		} )
+	).toBeVisible();
+	await expect( page.locator( '#submit' ) ).toBeDisabled();
+	await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+
+	await page.reload();
+	await expect( page.getByLabel( 'Zone name' ) ).toHaveValue( renamedZone );
+	await expect(
+		page.locator( '.wc-shipping-zone-method-title', {
+			hasText: 'Flat rate',
+		} )
+	).toBeVisible();
+	await expect(
+		page.locator( '.wc-shipping-zone-method-title', {
+			hasText: 'E2E Non-Modal Method',
+		} )
+	).toBeVisible();
+	expect( dialogMessages ).toEqual( [] );
+
+	const methods = await restApi.get(
+		`${ WC_API_PATH }/shipping/zones/${ zone.id }/methods`
+	);
+	expect(
+		methods.data.map(
+			( method: { method_id: string } ) => method.method_id
+		)
+	).toContain( 'e2e_nonmodal' );
+
+	const savedZone = await restApi.get(
+		`${ WC_API_PATH }/shipping/zones/${ zone.id }`
+	);
+	expect( savedZone.data.name ).toBe( renamedZone );
+} );