Commit 7c00c0210e8 for woocommerce

commit 7c00c0210e802f5388e19233d87f22d4401131c7
Author: Alefe Souza <contact@alefesouza.com>
Date:   Fri Sep 11 13:58:02 2026 -0300

    Recalculate shipping options when the country changes at checkout (#68364)

diff --git a/plugins/woocommerce/changelog/57940-checkout-country-change-shipping-rates b/plugins/woocommerce/changelog/57940-checkout-country-change-shipping-rates
new file mode 100644
index 00000000000..580d36fd31f
--- /dev/null
+++ b/plugins/woocommerce/changelog/57940-checkout-country-change-shipping-rates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Recalculate shipping options as soon as the country is changed at checkout, instead of keeping the previous country's options until the rest of the address is filled in.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/test/block.tsx
index f0b7fb72688..05b59ee4a89 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/test/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/test/block.tsx
@@ -558,11 +558,14 @@ describe( 'Testing Checkout', () => {
 			}
 		} );

-		// wait for form to be ready
-		await waitFor( () =>
-			expect(
-				screen.getByRole( 'button', { name: /Place order/i } )
-			).toBeVisible()
+		// Wait for the form to be ready. Changing the country recalculates the cart, which
+		// disables the button until the push settles, so allow for the 1.5s push debounce.
+		await waitFor(
+			() =>
+				expect(
+					screen.getByRole( 'button', { name: /Place order/i } )
+				).toBeEnabled(),
+			{ timeout: 5000 }
 		);

 		// Submit the form
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/push-changes.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/push-changes.ts
index d0637b74e31..ce7faa02953 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/push-changes.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/push-changes.ts
@@ -103,6 +103,35 @@ const updateDirtyProps = () => {
 	}
 };

+/**
+ * Fields a country change resets, since their values only make sense for the previous country.
+ */
+const countryDependentFields = [ 'state', 'postcode' ] as const;
+
+/**
+ * Returns the dirty props that need to pass validation before the address can be pushed.
+ *
+ * When the country changes, the state and postcode are reset and are therefore invalid until
+ * the customer fills them in again. Waiting for them would leave the server calculating
+ * shipping for the previous country, so they are skipped while they are still empty.
+ */
+const getDirtyPropsToValidate = (
+	dirtyProps: BaseAddressKey[],
+	address: CartBillingAddress | CartShippingAddress
+): BaseAddressKey[] => {
+	if ( ! dirtyProps.includes( 'country' ) ) {
+		return dirtyProps;
+	}
+
+	const emptiedByCountryChange = countryDependentFields.filter(
+		( field ) => ! address[ field ]
+	) as BaseAddressKey[];
+
+	return dirtyProps.filter(
+		( key ) => ! emptiedByCountryChange.includes( key )
+	);
+};
+
 /**
  * Function to dispatch an update to the server.
  */
@@ -131,7 +160,18 @@ const updateCustomerData = (): void => {
 	}

 	// Check props are valid, or abort.
-	if ( ! validateDirtyProps( localState.dirtyProps ) ) {
+	if (
+		! validateDirtyProps( {
+			billingAddress: getDirtyPropsToValidate(
+				localState.dirtyProps.billingAddress,
+				localState.customerData.billingAddress
+			),
+			shippingAddress: getDirtyPropsToValidate(
+				localState.dirtyProps.shippingAddress,
+				localState.customerData.shippingAddress
+			),
+		} )
+	) {
 		localState.doingPush = false;
 		return;
 	}
@@ -193,23 +233,44 @@ export const pushChanges = ( debounced = true ): void => {
 		return;
 	}

-	if (
-		isShallowEqual(
-			localState.customerData,
-			select( cartStore ).getCustomerData()
-		)
-	) {
+	const customerData = select( cartStore ).getCustomerData();
+
+	if ( isShallowEqual( localState.customerData, customerData ) ) {
 		return;
 	}

-	if ( debounced ) {
-		debouncedUpdateCustomerData();
-	} else {
+	if ( ! debounced ) {
+		updateCustomerData();
+		return;
+	}
+
+	debouncedUpdateCustomerData();
+
+	// Picking a country is a deliberate choice rather than typing, and it invalidates the
+	// shipping rates already on screen. Push it straight away so the rates reload instead of
+	// showing the previous country's options for the length of the debounce.
+	const countryChanged =
+		customerData.billingAddress.country !==
+			localState.customerData.billingAddress.country ||
+		customerData.shippingAddress.country !==
+			localState.customerData.shippingAddress.country;
+
+	if ( countryChanged ) {
+		// Push now rather than flushing the debounce: while a push is running this is a no-op,
+		// and the run scheduled above still sends the change once that push finishes.
 		updateCustomerData();
 	}
 };

 // Cancel the debounced updateCustomerData function and trigger it immediately.
 export const flushChanges = (): void => {
-	debouncedUpdateCustomerData.flush();
+	if ( localState.doingPush ) {
+		// A push is already running, so this one would be a no-op anyway. Leave the scheduled
+		// run alone: flushing it here would cancel it, and the changes made during the running
+		// push would never reach the server.
+		return;
+	}
+
+	debouncedUpdateCustomerData.clear();
+	updateCustomerData();
 };
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/push-changes.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/push-changes.ts
index c5d9af019e0..d33c9c8fabc 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/push-changes.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/push-changes.ts
@@ -7,9 +7,10 @@ import { cartStore, validationStore } from '@woocommerce/block-data';
 /**
  * Internal dependencies
  */
-import { pushChanges } from '../push-changes';
+import { flushChanges, pushChanges } from '../push-changes';

 let updateCustomerDataMock = jest.fn();
+const getValidationErrorMock = jest.fn().mockReturnValue( undefined );
 let getCustomerDataMock = jest.fn().mockReturnValue( {
 	billingAddress: {
 		first_name: 'John',
@@ -96,6 +97,34 @@ async function resetToInitialAddressMock() {
 	pushChanges( false );
 }

+// The address every test starts from, matching what resetToInitialAddressMock restores.
+// Always spread these rather than passing them straight through: updateDirtyProps mutates the
+// address it is given, so a shared reference would leak changes into later tests.
+const initialBillingAddress = {
+	first_name: 'John',
+	last_name: 'Doe',
+	address_1: '123 Main St',
+	address_2: '',
+	city: 'New York',
+	state: 'NY',
+	postcode: '10001',
+	country: 'US',
+	email: 'john.doe@mail.com',
+	phone: '555-555-5555',
+};
+
+const initialShippingAddress = {
+	first_name: 'John',
+	last_name: 'Doe',
+	address_1: '123 Main St',
+	address_2: '',
+	city: 'New York',
+	state: 'NY',
+	postcode: '10001',
+	country: 'US',
+	phone: '555-555-5555',
+};
+
 describe( 'pushChanges', () => {
 	beforeAll( () => {
 		wpDataFunctions.select.mockImplementation(
@@ -109,14 +138,15 @@ describe( 'pushChanges', () => {
 						getCustomerData: getCustomerDataMock,
 					};
 				}
-				if ( storeNameOrDescriptor === validationStore ) {
+				if (
+					storeNameOrDescriptor === validationStore ||
+					storeNameOrDescriptor === validationStore.name
+				) {
 					return {
 						...jest
 							.requireActual( '@wordpress/data' )
 							.select( storeNameOrDescriptor ),
-						getValidationError: jest
-							.fn()
-							.mockReturnValue( undefined ),
+						getValidationError: getValidationErrorMock,
 					};
 				}
 				return jest
@@ -140,8 +170,10 @@ describe( 'pushChanges', () => {
 			}
 		);
 	} );
-	beforeEach( () => {
-		resetToInitialAddressMock();
+	beforeEach( async () => {
+		getValidationErrorMock.mockReset();
+		getValidationErrorMock.mockReturnValue( undefined );
+		await resetToInitialAddressMock();
 	} );

 	it( 'Keeps props dirty if data did not persist due to an error', async () => {
@@ -478,4 +510,368 @@ describe( 'pushChanges', () => {
 			false // because no shipping rate impacting fields are changed
 		);
 	} );
+
+	it( 'Pushes the address when the country changes, even though the reset state and postcode are invalid', async () => {
+		getValidationErrorMock.mockImplementation( ( key: string ) =>
+			[ 'shipping_state', 'shipping_postcode' ].includes( key )
+				? { message: 'Please enter a valid postcode', hidden: true }
+				: undefined
+		);
+
+		// Changing the country resets the state and postcode, like the address form does.
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: {
+				first_name: 'John',
+				last_name: 'Doe',
+				address_1: '123 Main St',
+				address_2: '',
+				city: 'New York',
+				state: 'NY',
+				postcode: '10001',
+				country: 'US',
+				email: 'john.doe@mail.com',
+				phone: '555-555-5555',
+			},
+			shippingAddress: {
+				first_name: 'John',
+				last_name: 'Doe',
+				address_1: '123 Main St',
+				address_2: '',
+				city: 'New York',
+				state: '',
+				postcode: '',
+				country: 'GB',
+				phone: '555-555-5555',
+			},
+		} );
+
+		pushChanges( false );
+
+		await expect( updateCustomerDataMock ).toHaveBeenLastCalledWith(
+			{
+				shipping_address: {
+					first_name: 'John',
+					last_name: 'Doe',
+					address_1: '123 Main St',
+					address_2: '',
+					city: 'New York',
+					state: '',
+					postcode: '',
+					country: 'GB',
+					phone: '555-555-5555',
+				},
+			},
+			true,
+			true // because the shipping rate impacting field was changed
+		);
+	} );
+
+	it( 'Does not push the address if the postcode entered after a country change is invalid', async () => {
+		updateCustomerDataMock.mockClear();
+		getValidationErrorMock.mockImplementation( ( key: string ) =>
+			key === 'shipping_postcode'
+				? { message: 'Please enter a valid postcode', hidden: true }
+				: undefined
+		);
+
+		const billingAddress = {
+			first_name: 'John',
+			last_name: 'Doe',
+			address_1: '123 Main St',
+			address_2: '',
+			city: 'New York',
+			state: 'NY',
+			postcode: '10001',
+			country: 'US',
+			email: 'john.doe@mail.com',
+			phone: '555-555-5555',
+		};
+		const shippingAddress = {
+			first_name: 'John',
+			last_name: 'Doe',
+			address_1: '123 Main St',
+			address_2: '',
+			city: 'New York',
+			state: '',
+			country: 'GB',
+			phone: '555-555-5555',
+		};
+
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress,
+			shippingAddress: { ...shippingAddress, postcode: 'INVALID' },
+		} );
+
+		pushChanges( false );
+
+		expect( updateCustomerDataMock ).not.toHaveBeenCalled();
+
+		// Correcting the postcode unblocks the push, proving nothing else was holding it back.
+		getValidationErrorMock.mockReturnValue( undefined );
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress,
+			shippingAddress: { ...shippingAddress, postcode: 'SW1A 2AA' },
+		} );
+
+		pushChanges( false );
+
+		await expect( updateCustomerDataMock ).toHaveBeenLastCalledWith(
+			{ shipping_address: { ...shippingAddress, postcode: 'SW1A 2AA' } },
+			true,
+			true // because the shipping rate impacting field was changed
+		);
+	} );
+
+	it( 'Pushes a country change straight away instead of waiting out the debounce', () => {
+		updateCustomerDataMock.mockClear();
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: {
+				first_name: 'John',
+				last_name: 'Doe',
+				address_1: '123 Main St',
+				address_2: '',
+				city: 'New York',
+				state: 'NY',
+				postcode: '10001',
+				country: 'US',
+				email: 'john.doe@mail.com',
+				phone: '555-555-5555',
+			},
+			shippingAddress: {
+				first_name: 'John',
+				last_name: 'Doe',
+				address_1: '123 Main St',
+				address_2: '',
+				city: 'New York',
+				state: '',
+				postcode: '',
+				country: 'GB',
+				phone: '555-555-5555',
+			},
+		} );
+
+		// Debounced, but the shipping rates on screen are stale as soon as the country changes.
+		pushChanges();
+
+		expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'Waits for the debounce when a field other than the country changes', () => {
+		jest.useFakeTimers();
+		updateCustomerDataMock.mockClear();
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: {
+				first_name: 'John',
+				last_name: 'Doe',
+				address_1: '123 Main St',
+				address_2: '',
+				city: 'New York',
+				state: 'NY',
+				postcode: '10001',
+				country: 'US',
+				email: 'john.doe@mail.com',
+				phone: '555-555-5555',
+			},
+			shippingAddress: {
+				first_name: 'John',
+				last_name: 'Doe',
+				address_1: '123 Main St',
+				address_2: '',
+				city: 'Houston',
+				state: 'NY',
+				postcode: '10001',
+				country: 'US',
+				phone: '555-555-5555',
+			},
+		} );
+
+		pushChanges();
+
+		expect( updateCustomerDataMock ).not.toHaveBeenCalled();
+
+		jest.advanceTimersByTime( 1500 );
+
+		expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+
+		jest.useRealTimers();
+	} );
+
+	it( 'Pushes the address when the billing country changes, even though the reset state and postcode are invalid', async () => {
+		updateCustomerDataMock.mockClear();
+		getValidationErrorMock.mockImplementation( ( key: string ) =>
+			[ 'billing_state', 'billing_postcode' ].includes( key )
+				? { message: 'Please enter a valid postcode', hidden: true }
+				: undefined
+		);
+
+		// Changing the country resets the state and postcode, like the address form does.
+		const billingAddress = {
+			...initialBillingAddress,
+			state: '',
+			postcode: '',
+			country: 'GB',
+		};
+
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress,
+			shippingAddress: { ...initialShippingAddress },
+		} );
+
+		pushChanges( false );
+
+		await expect( updateCustomerDataMock ).toHaveBeenLastCalledWith(
+			{ billing_address: billingAddress },
+			true,
+			false // because no shipping rate impacting fields are changed
+		);
+	} );
+
+	it( 'Still waits for a valid postcode when it was emptied without the country changing', async () => {
+		updateCustomerDataMock.mockClear();
+		getValidationErrorMock.mockImplementation( ( key: string ) =>
+			key === 'shipping_postcode'
+				? { message: 'Please enter a valid postcode', hidden: true }
+				: undefined
+		);
+
+		// Same country throughout: the customer cleared the postcode themselves.
+		const shippingAddress = { ...initialShippingAddress, city: 'Boston' };
+
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: { ...initialBillingAddress },
+			shippingAddress: { ...shippingAddress, postcode: '' },
+		} );
+
+		pushChanges( false );
+
+		// The country did not change, so an empty postcode is the customer's own doing and is
+		// still validated. Only a country change earns the exemption.
+		expect( updateCustomerDataMock ).not.toHaveBeenCalled();
+
+		// Filling it in unblocks the push, proving nothing else was holding it back.
+		getValidationErrorMock.mockReturnValue( undefined );
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: { ...initialBillingAddress },
+			shippingAddress: { ...shippingAddress, postcode: '02101' },
+		} );
+
+		pushChanges( false );
+
+		await expect( updateCustomerDataMock ).toHaveBeenLastCalledWith(
+			{ shipping_address: { ...shippingAddress, postcode: '02101' } },
+			true,
+			true // because the shipping rate impacting field was changed
+		);
+	} );
+	it( 'Does not lose a country change made while a push is already running', async () => {
+		jest.useFakeTimers();
+		updateCustomerDataMock.mockClear();
+
+		// Keep the first push running so the country changes while it is still in flight.
+		let resolveFirstPush: () => void = () => undefined;
+		updateCustomerDataMock.mockReturnValueOnce(
+			new Promise< void >( ( resolve ) => {
+				resolveFirstPush = resolve;
+			} )
+		);
+
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: { ...initialBillingAddress },
+			shippingAddress: { ...initialShippingAddress, city: 'Houston' },
+		} );
+
+		pushChanges( false );
+
+		expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+
+		const countryChangedAddress = {
+			...initialShippingAddress,
+			city: 'Houston',
+			country: 'GB',
+			state: '',
+			postcode: '',
+		};
+
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: { ...initialBillingAddress },
+			shippingAddress: { ...countryChangedAddress },
+		} );
+
+		pushChanges();
+
+		// Pushes do not overlap, so the country change waits for the running one.
+		expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+
+		resolveFirstPush();
+		await Promise.resolve();
+		jest.advanceTimersByTime( 1500 );
+
+		expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 2 );
+		expect( updateCustomerDataMock ).toHaveBeenLastCalledWith(
+			{ shipping_address: { ...countryChangedAddress } },
+			true,
+			true // because the shipping rate impacting field was changed
+		);
+
+		jest.useRealTimers();
+	} );
+	it( 'Keeps a scheduled push when a field is blurred while a push is running', async () => {
+		jest.useFakeTimers();
+		updateCustomerDataMock.mockClear();
+
+		// Keep the first push running so the next change is made while it is in flight.
+		let resolveFirstPush: () => void = () => undefined;
+		updateCustomerDataMock.mockReturnValueOnce(
+			new Promise< void >( ( resolve ) => {
+				resolveFirstPush = resolve;
+			} )
+		);
+
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: {
+				...initialBillingAddress,
+				email: 'jane.doe@mail.com',
+			},
+			shippingAddress: { ...initialShippingAddress },
+		} );
+
+		pushChanges( false );
+
+		expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+
+		const typedAddress = {
+			...initialShippingAddress,
+			city: 'Houston',
+			state: 'TX',
+			postcode: '77058',
+		};
+
+		getCustomerDataMock.mockReturnValue( {
+			billingAddress: {
+				...initialBillingAddress,
+				email: 'jane.doe@mail.com',
+			},
+			shippingAddress: { ...typedAddress },
+		} );
+
+		pushChanges();
+
+		// Blurring the field flushes, which must not cancel the push scheduled above.
+		flushChanges();
+
+		expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+
+		resolveFirstPush();
+		await Promise.resolve();
+		jest.advanceTimersByTime( 1500 );
+
+		expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 2 );
+		expect( updateCustomerDataMock ).toHaveBeenLastCalledWith(
+			{ shipping_address: { ...typedAddress } },
+			true,
+			true // because the shipping rate impacting field was changed
+		);
+
+		jest.useRealTimers();
+	} );
 } );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/checkout/checkout-block-extensibility.shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/checkout/checkout-block-extensibility.shopper.block_theme.spec.ts
index 1986d69b940..e7a05fda2ba 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/checkout/checkout-block-extensibility.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/checkout/checkout-block-extensibility.shopper.block_theme.spec.ts
@@ -85,38 +85,61 @@ test.describe( 'Shopper → Extensibility', () => {
 		test( 'Unpushed data is/is not overwritten depending on arg', async ( {
 			checkoutPageObject,
 		} ) => {
-			// First test by only partially filling in the address form.
-			await checkoutPageObject.page
-				.getByLabel( 'Country/Region' )
-				.selectOption( 'United Kingdom (UK)' );
-			await checkoutPageObject.page.getByLabel( 'Country/Region' ).blur();
+			// Fill in the address, then wait until it has reached the server. The dirty flag is
+			// not enough on its own: it is cleared by whichever push finishes first, which can
+			// be an earlier one that did not carry the address.
+			await checkoutPageObject.fillInCheckoutWithTestData();
+			await expect
+				.poll(
+					async () =>
+						checkoutPageObject.page.evaluate( async () => {
+							const response = await fetch(
+								'/wp-json/wc/store/v1/cart'
+							);
+							if ( ! response.ok ) {
+								return null;
+							}
+							const cart = await response.json();
+							const postcode = cart?.shipping_address?.postcode;
+							return typeof postcode === 'string'
+								? postcode
+								: null;
+						} ),
+					{ timeout: 15000 }
+				)
+				.toBe( '90210' );

+			// A postcode that fails validation is never pushed, so it only exists in the browser.
+			const postcode =
+				checkoutPageObject.page.locator( '#shipping-postcode' );
+			await postcode.fill( 'ABCDEF' );
+			await postcode.blur();
+			await checkoutPageObject.page.waitForFunction(
+				() =>
+					window.localStorage.getItem(
+						'WOOCOMMERCE_CHECKOUT_IS_CUSTOMER_DATA_DIRTY'
+					) === 'true'
+			);
+
+			// Without the arg, the unpushed postcode is kept.
 			await checkoutPageObject.page.evaluate(
 				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update' } )"
 			);
-			await expect(
-				checkoutPageObject.page.getByLabel( 'Country/Region' )
-			).toHaveValue( 'GB' );
+			await expect( postcode ).toHaveValue( 'ABCDEF' );
+
+			// With overwriteDirtyCustomerData, the address from the server replaces it.
 			await checkoutPageObject.page.evaluate(
 				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update', overwriteDirtyCustomerData: true } )"
 			);
-			await expect(
-				checkoutPageObject.page.getByLabel( 'Country/Region' )
-			).not.toHaveValue( 'GB' );
+			await expect( postcode ).toHaveValue( '90210' );

-			// Next fully test the address form (so it pushes), then run extensionCartUpdate with
-			// overwriteDirtyCustomerData: true so overwriting is possible, but since the address pushed it should not
-			// be overwritten.
-			await checkoutPageObject.fillInCheckoutWithTestData();
-			await expect(
-				checkoutPageObject.page.getByLabel( 'Country/Region' )
-			).toHaveValue( 'US' );
 			await checkoutPageObject.page.evaluate(
 				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update', overwriteDirtyCustomerData: true } )"
 			);
 			await expect(
 				checkoutPageObject.page.getByLabel( 'Country/Region' )
 			).toHaveValue( 'US' );
+			await expect( postcode ).toHaveValue( '90210' );
 		} );
 		test( 'Cart data can be modified by extensions', async ( {
 			checkoutPageObject,