Commit 6d0167e8a65 for woocommerce
commit 6d0167e8a65f6d67f270e7f06fc07809e316fd28
Author: Raluca Stan <ralucastn@gmail.com>
Date: Tue Oct 20 14:38:50 2026 +0200
Disable Place order while an address change updates shipping costs (#69896)
* Disable Place order while an address change waits to update shipping
* Add changelog entry for the Place order disabled state fix
* Send state changes, and changes made during a push, without waiting for the debounce
* Reword the changelog entry for the Place order disabled state
* Describe why checkout waits for queued address changes in comments
* Fold the immediate push into flushChanges and name its flag after what it does
* Fix typed state values being pushed on every keystroke
Countries without a state list use a text input for the state, so only
push a state change straight away when it was picked from a list.
---------
Co-authored-by: Mike Jolley <mike.jolley@me.com>
diff --git a/plugins/woocommerce/changelog/69892-place-order-disabled-rates-stale b/plugins/woocommerce/changelog/69892-place-order-disabled-rates-stale
new file mode 100644
index 00000000000..f32d69d87cc
--- /dev/null
+++ b/plugins/woocommerce/changelog/69892-place-order-disabled-rates-stale
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show the Place order button as disabled while shipping costs update after an address change, and update them sooner after picking a state.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/test/use-checkout-submit.js b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/test/use-checkout-submit.js
index 0c8785b533b..ab598a22940 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/test/use-checkout-submit.js
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/test/use-checkout-submit.js
@@ -1,12 +1,16 @@
/**
* External dependencies
*/
-import { renderHook } from '@testing-library/react';
+import { act, renderHook } from '@testing-library/react';
import { createRegistry, RegistryProvider } from '@wordpress/data';
/**
* Internal dependencies
*/
+import {
+ CART_STORE_KEY,
+ config as cartStoreConfig,
+} from '@woocommerce/block-data/cart';
import {
CHECKOUT_STORE_KEY,
config as checkoutStoreConfig,
@@ -38,6 +42,7 @@ describe( 'useCheckoutSubmit', () => {
beforeEach( () => {
registry = createRegistry( {
+ [ CART_STORE_KEY ]: cartStoreConfig,
[ CHECKOUT_STORE_KEY ]: checkoutStoreConfig,
[ PAYMENT_STORE_KEY ]: paymentDataStoreConfig,
} );
@@ -54,4 +59,28 @@ describe( 'useCheckoutSubmit', () => {
expect( onSubmit ).toHaveBeenCalledTimes( 1 );
} );
+
+ it( 'keeps the place order button disabled while a shipping address change waits to be sent', () => {
+ const { result } = renderHook( () => useCheckoutSubmit(), {
+ wrapper,
+ } );
+
+ expect( result.current.isCalculating ).toBe( false );
+
+ act( () => {
+ registry
+ .dispatch( CART_STORE_KEY )
+ .__internalSetShippingRatesUpdateQueued( true );
+ } );
+
+ expect( result.current.isCalculating ).toBe( true );
+
+ act( () => {
+ registry
+ .dispatch( CART_STORE_KEY )
+ .__internalSetShippingRatesUpdateQueued( false );
+ } );
+
+ expect( result.current.isCalculating ).toBe( false );
+ } );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/use-checkout-submit.js b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/use-checkout-submit.js
index b8701969a5e..28aa974f137 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/use-checkout-submit.js
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/use-checkout-submit.js
@@ -1,7 +1,11 @@
/**
* External dependencies
*/
-import { checkoutStore, paymentStore } from '@woocommerce/block-data';
+import {
+ cartStore,
+ checkoutStore,
+ paymentStore,
+} from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
/**
@@ -25,7 +29,11 @@ export const useCheckoutSubmit = () => {
} = useSelect( ( select ) => {
const store = select( checkoutStore );
return {
- isCalculating: store.isCalculating(),
+ // Shipping address changes are sent after a debounce, so the shipping rates are out of date before the
+ // request starts. Count that wait as calculating, so the checkout stays disabled from the change to the new rates.
+ isCalculating:
+ store.isCalculating() ||
+ select( cartStore ).isShippingRatesStale(),
isBeforeProcessing: store.isBeforeProcessing(),
isProcessing: store.isProcessing(),
isAfterProcessing: store.isAfterProcessing(),
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 74a6ab29378..c82fae8eecd 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
@@ -5,6 +5,7 @@ import {
debounce,
isAddressFieldForShippingRates,
} from '@woocommerce/base-utils';
+import { STATES } from '@woocommerce/block-settings';
import { CartBillingAddress, CartShippingAddress } from '@woocommerce/types';
import { select, dispatch } from '@wordpress/data';
import isShallowEqual from '@wordpress/is-shallow-equal';
@@ -22,6 +23,8 @@ const localState = {
customerDataIsInitialized: false,
// True when a push is currently happening to avoid simultaneous pushes.
doingPush: false,
+ // True when changes were flushed while a push was running, so they are sent when it ends.
+ flushWhenPushEnds: false,
// Local cache of the last pushed customerData used for comparisons.
customerData: {
billingAddress: {} as CartBillingAddress,
@@ -132,6 +135,12 @@ const getDirtyPropsToValidate = (
);
};
+/**
+ * Returns true when the country's state field is a list to pick from. Countries without one use a text input.
+ */
+const hasStateList = ( country: string ): boolean =>
+ Object.keys( STATES[ country ] ?? {} ).length > 0;
+
const isShippingRatesUpdateQueued = () =>
!! select( cartStore ).getCartMeta().isShippingRatesUpdateQueued;
@@ -197,6 +206,15 @@ const updateCustomerData = (): void => {
isAddressFieldForShippingRates
);
+ const endPush = () => {
+ localState.doingPush = false;
+
+ if ( localState.flushWhenPushEnds ) {
+ localState.flushWhenPushEnds = false;
+ updateCustomerData();
+ }
+ };
+
dispatch( cartStore )
.updateCustomerData(
{
@@ -213,11 +231,11 @@ const updateCustomerData = (): void => {
.then( () => {
localState.dirtyProps.billingAddress = [];
localState.dirtyProps.shippingAddress = [];
- localState.doingPush = false;
+ endPush();
} )
.catch( ( response ) => {
- localState.doingPush = false;
processErrorResponse( response );
+ endPush();
} );
// Cleared once the request has started, so the shipping rates loading state takes over without a gap.
@@ -235,6 +253,20 @@ const debouncedUpdateCustomerData = debounce( () => {
updateCustomerData();
}, 1500 );
+/**
+ * Sends the changes without waiting for the debounce. Pushes do not overlap, so while one is running
+ * the changes are sent when it ends; the scheduled run is kept as a fallback and finds nothing left to send.
+ */
+export const flushChanges = (): void => {
+ if ( localState.doingPush ) {
+ localState.flushWhenPushEnds = true;
+ return;
+ }
+
+ debouncedUpdateCustomerData.clear();
+ updateCustomerData();
+};
+
/**
* After cart has fully initialized, pushes changes to the server when data in the store is changed. Updates to the
* server are debounced to prevent excessive requests.
@@ -275,31 +307,23 @@ export const pushChanges = ( debounced = true ): void => {
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();
- }
-};
+ // Picking a country, or a state from a list, is a deliberate choice rather than typing, and it
+ // invalidates the shipping rates already on screen. Push it straight away so the rates reload
+ // without waiting for the debounce. A state typed into a text input waits like any other field.
+ const pickedFieldChanged = (
+ [ 'billingAddress', 'shippingAddress' ] as const
+ ).some( ( addressType ) => {
+ const address = customerData[ addressType ];
+ const previousAddress = localState.customerData[ addressType ];
+
+ return (
+ address.country !== previousAddress.country ||
+ ( address.state !== previousAddress.state &&
+ hasStateList( address.country ) )
+ );
+ } );
-// Cancel the debounced updateCustomerData function and trigger it immediately.
-export const flushChanges = (): void => {
- 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;
+ if ( pickedFieldChanged ) {
+ flushChanges();
}
-
- 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 de01100dcee..418a9593d80 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
@@ -49,6 +49,16 @@ jest.mock( '@woocommerce/utils', () => ( {
isSiteEditorPage: jest.fn().mockReturnValue( true ),
} ) );
+// US has a state list to pick from. GB does not, so its state is typed into a text input.
+jest.mock( '@woocommerce/block-settings', () => ( {
+ ...jest.requireActual( '@woocommerce/block-settings' ),
+ __esModule: true,
+ STATES: {
+ US: { NY: 'New York', TX: 'Texas' },
+ GB: {},
+ },
+} ) );
+
// Mocking processErrorResponse because we don't actually care about processing the error response, we just don't want
// pushChanges to throw an error.
jest.mock( '../../utils', () => ( {
@@ -656,7 +666,7 @@ describe( 'pushChanges', () => {
expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
} );
- it( 'Waits for the debounce when a field other than the country changes', () => {
+ it( 'Waits for the debounce when a field other than the country or state changes', () => {
jest.useFakeTimers();
updateCustomerDataMock.mockClear();
getCustomerDataMock.mockReturnValue( {
@@ -696,6 +706,76 @@ describe( 'pushChanges', () => {
jest.useRealTimers();
} );
+ it( 'Pushes a state 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: 'Houston',
+ state: 'TX',
+ postcode: '10001',
+ country: 'US',
+ phone: '555-555-5555',
+ },
+ } );
+
+ // Picking a state from the list is not typing, so the shopper should not wait for the debounce.
+ pushChanges();
+
+ expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'Waits for the debounce when the state is typed rather than picked from a list', async () => {
+ jest.useFakeTimers();
+ const shippingAddress = {
+ ...initialShippingAddress,
+ city: 'London',
+ state: '',
+ postcode: 'SW1A 2AA',
+ country: 'GB',
+ };
+ getCustomerDataMock.mockReturnValue( {
+ billingAddress: { ...initialBillingAddress },
+ shippingAddress,
+ } );
+
+ // The country change is pushed straight away.
+ pushChanges();
+ await Promise.resolve();
+ updateCustomerDataMock.mockClear();
+
+ getCustomerDataMock.mockReturnValue( {
+ billingAddress: { ...initialBillingAddress },
+ shippingAddress: { ...shippingAddress, state: 'Greater Lon' },
+ } );
+
+ pushChanges();
+
+ // Pushing every keystroke would send each partial county to the server.
+ 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 ) =>
@@ -802,9 +882,9 @@ describe( 'pushChanges', () => {
// Pushes do not overlap, so the country change waits for the running one.
expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+ // It is sent as soon as the running push finishes, without waiting out the debounce.
resolveFirstPush();
await Promise.resolve();
- jest.advanceTimersByTime( 1500 );
expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 2 );
expect( updateCustomerDataMock ).toHaveBeenLastCalledWith(
@@ -813,9 +893,13 @@ describe( 'pushChanges', () => {
true // because the shipping rate impacting field was changed
);
+ // The scheduled run finds nothing left to send, so the change is not sent twice.
+ jest.advanceTimersByTime( 1500 );
+ expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 2 );
+
jest.useRealTimers();
} );
- it( 'Keeps a scheduled push when a field is blurred while a push is running', async () => {
+ it( 'Sends a field blurred while a push is running as soon as that push finishes', async () => {
jest.useFakeTimers();
updateCustomerDataMock.mockClear();
@@ -856,14 +940,13 @@ describe( 'pushChanges', () => {
pushChanges();
- // Blurring the field flushes, which must not cancel the push scheduled above.
+ // Blurring the field flushes; pushes do not overlap, so it waits for the running one.
flushChanges();
expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
resolveFirstPush();
await Promise.resolve();
- jest.advanceTimersByTime( 1500 );
expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 2 );
expect( updateCustomerDataMock ).toHaveBeenLastCalledWith(
@@ -872,6 +955,9 @@ describe( 'pushChanges', () => {
true // because the shipping rate impacting field was changed
);
+ jest.advanceTimersByTime( 1500 );
+ expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 2 );
+
jest.useRealTimers();
} );