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