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,