Commit d93e3c055e8 for woocommerce

commit d93e3c055e870eba1668ccefb1c5f5bcec436859
Author: Mike Jolley <mike.jolley@me.com>
Date:   Wed Oct 14 17:09:01 2026 +0100

    Avoid no-shipping method warnings until address is pushed to the server (#69450)

    * Avoid no-shipping method warnings until address is pushed to the server

    Add an isShippingRatesStale() cart selector that is true from a
    rate-affecting address change until the server returns rates for it, and
    show the shipping skeleton in the Checkout block while it is true instead
    of the "No shipping options" warning. The skeleton now renders one row per
    rate on screen to reduce layout shift while rates reload.

    Closes #53903.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Document shipping rates keys in getCartMeta() docs

    Add isShippingRatesUpdateQueued and the previously undocumented
    updatingAddressFieldsForShippingRates to the getCartMeta() return keys.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Keep shipping method notices mounted while rates load

    Render the shipping methods notice container alongside the skeleton so its
    notices stay in place instead of moving to the main checkout notices, and
    check the address only once.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Make isShippingRatesUpdateQueued optional in CartMeta

    CartMeta is exported from @woocommerce/types, so keep existing typed
    consumers compatible.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Show a loading placeholder for order summary delivery while rates are stale

    The Delivery row showed "No available delivery option" while a completed
    address was waiting to be sent. Show the price skeleton instead until the
    server returns rates.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

diff --git a/docs/block-development/reference/data-store/cart.md b/docs/block-development/reference/data-store/cart.md
index df184049f01..13416b49d78 100644
--- a/docs/block-development/reference/data-store/cart.md
+++ b/docs/block-development/reference/data-store/cart.md
@@ -660,6 +660,8 @@ Returns the cart meta from state.

 -   `object`: The current cart meta with the following keys:
     -   _updatingCustomerData_ `boolean`: If the customer data (billing and/or shipping address) is being updated.
+    -   _updatingAddressFieldsForShippingRates_ `boolean`: If shipping address fields impacting the shipping rates are being updated.
+    -   _isShippingRatesUpdateQueued_ `boolean`: If shipping address fields impacting the shipping rates have changed but have not been sent to the server yet. Use `isShippingRatesStale()` to check whether the shipping rates are out of date.
     -   _updatingSelectedRate_ `boolean`: If the selected rate is being updated.
     -   _isCartDataStale_ `boolean`: If the cart data is stale.
     -   _applyingCoupon_ `string`: The coupon code being applied.
@@ -894,6 +896,21 @@ const store = select( cartStore );
 const isAddressFieldsForShippingRatesUpdating = store.isAddressFieldsForShippingRatesUpdating();
 ```

+### isShippingRatesStale
+
+Queries whether the shipping rates in the store are out of date because shipping address fields that affect them have changed. This is true from the moment the address changes, including while the change is waiting to be sent to the server, until the server has returned rates for the new address.
+
+#### _Returns_
+
+-   `boolean`: True if the shipping rates do not reflect the current shipping address yet.
+
+#### _Example_
+
+```js
+const store = select( cartStore );
+const isShippingRatesStale = store.isShippingRatesStale();
+```
+
 ### hasPendingItemsOperations

 Queries whether there are any pending cart operations (add, quantity update, or delete items).
diff --git a/plugins/woocommerce/changelog/53903-checkout-shipping-options-loading-state b/plugins/woocommerce/changelog/53903-checkout-shipping-options-loading-state
new file mode 100644
index 00000000000..b029702c930
--- /dev/null
+++ b/plugins/woocommerce/changelog/53903-checkout-shipping-options-loading-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show a loading placeholder at checkout while shipping options update, instead of briefly warning that none are available.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx
index 7784248d7c9..871083782a5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx
@@ -186,8 +186,9 @@ const ShippingRatesControl = ( {
 		return rate === selectedRateIds[ 0 ];
 	} );

+	// Match the rates on screen so the layout does not jump while they reload.
 	if ( isLoadingRates ) {
-		return <CheckoutShippingSkeleton />;
+		return <CheckoutShippingSkeleton rows={ shippingRatesRateCount } />;
 	}

 	return (
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/loading.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/loading.tsx
new file mode 100644
index 00000000000..724c5cac39c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/loading.tsx
@@ -0,0 +1,73 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { useStoreCart } from '@woocommerce/base-context';
+
+/**
+ * Internal dependencies
+ */
+import ShippingRatesControl from '..';
+import {
+	generateShippingPackage,
+	generateShippingRate,
+} from '../../../../../mocks/shipping-package';
+
+jest.mock( '@woocommerce/base-context', () => ( {
+	useStoreCart: jest.fn(),
+	useEditorContext: jest.fn( () => ( { isEditor: false } ) ),
+	useShippingData: jest.fn( () => ( {
+		hasSelectedLocalPickup: false,
+		selectedRates: {},
+	} ) ),
+} ) );
+
+const skeletonRowSelector = '.wc-block-components-skeleton__shipping-option';
+
+const renderLoading = (
+	shippingRates: ReturnType< typeof generateShippingPackage >[]
+) =>
+	render(
+		<ShippingRatesControl
+			shippingRates={ shippingRates }
+			isLoadingRates={ true }
+			context="woocommerce/checkout"
+		/>
+	);
+
+describe( 'ShippingRatesControl loading state', () => {
+	beforeEach( () => {
+		jest.mocked( useStoreCart ).mockReturnValue( {
+			extensions: {},
+			receiveCart: jest.fn(),
+		} as unknown as ReturnType< typeof useStoreCart > );
+	} );
+
+	it( 'shows a skeleton row for each rate already on screen', () => {
+		const { container } = renderLoading( [
+			generateShippingPackage( {
+				packageId: 0,
+				shippingRates: [ 1, 2, 3 ].map( ( instanceID ) =>
+					generateShippingRate( {
+						rateId: `flat_rate:${ instanceID }`,
+						name: `Rate ${ instanceID }`,
+						price: '1000',
+						instanceID,
+					} )
+				),
+			} ),
+		] );
+
+		expect(
+			container.querySelectorAll( skeletonRowSelector )
+		).toHaveLength( 3 );
+	} );
+
+	it( 'shows a single skeleton row when there are no rates on screen', () => {
+		const { container } = renderLoading( [] );
+
+		expect(
+			container.querySelectorAll( skeletonRowSelector )
+		).toHaveLength( 1 );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/skeleton/patterns/checkout-shipping/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/skeleton/patterns/checkout-shipping/index.tsx
index 0fbbab776a9..a44370b44b4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/skeleton/patterns/checkout-shipping/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/skeleton/patterns/checkout-shipping/index.tsx
@@ -9,16 +9,29 @@ import { __ } from '@wordpress/i18n';
 import { Skeleton } from '../..';
 import './style.scss';

-export const CheckoutShippingSkeleton = () => {
+export const CheckoutShippingSkeleton = ( { rows = 1 }: { rows?: number } ) => {
 	return (
 		<div
 			className="wc-block-components-skeleton wc-block-components-skeleton--checkout-shipping"
 			aria-live="polite"
 			aria-label={ __( 'Loading shipping options…', 'woocommerce' ) }
 		>
-			<Skeleton height="20px" width="20px" borderRadius="100%" />
-			<Skeleton height="20px" maxWidth="148px" />
-			<Skeleton height="20px" width="50px" />
+			{ Array.from( { length: Math.max( 1, rows ) } ).map(
+				( _, index ) => (
+					<div
+						className="wc-block-components-skeleton__shipping-option"
+						key={ index }
+					>
+						<Skeleton
+							height="20px"
+							width="20px"
+							borderRadius="100%"
+						/>
+						<Skeleton height="20px" maxWidth="148px" />
+						<Skeleton height="20px" width="50px" />
+					</div>
+				)
+			) }
 		</div>
 	);
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/skeleton/patterns/checkout-shipping/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/skeleton/patterns/checkout-shipping/style.scss
index 523375578de..212873de22a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/skeleton/patterns/checkout-shipping/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/skeleton/patterns/checkout-shipping/style.scss
@@ -1,9 +1,18 @@
+// Borders are drawn with inset box-shadows so they add no height, matching the rate options they stand in for.
 .wc-block-components-skeleton.wc-block-components-skeleton--checkout-shipping {
-	border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
+	box-shadow: inset 0 0 0 1px $universal-border-light;
 	border-radius: $universal-border-radius;
+	gap: 0;
+}
+
+.wc-block-components-skeleton__shipping-option {
 	padding: $gap;
 	display: grid;
 	grid-template-columns: 20px 1fr 50px;
 	gap: $gap;
 	align-items: center;
+
+	& + & {
+		box-shadow: inset 0 1px 0 0 $universal-border-light;
+	}
 }
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/shipping/types.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/shipping/types.ts
index d191ada754c..8c3d71e55d3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/shipping/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/shipping/types.ts
@@ -8,6 +8,8 @@ export interface ShippingData {
 	hasCalculatedShipping: Cart[ 'hasCalculatedShipping' ];
 	shippingRates: Cart[ 'shippingRates' ];
 	isLoadingRates: boolean;
+	// True from a shipping address change that affects the rates until the server has returned rates for it.
+	isShippingRatesStale: boolean;
 	selectedRates: Record< string, string | unknown >;
 	// Returns a function that accepts a shipping rate ID and a package ID.
 	selectShippingRate: (
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/shipping/use-shipping-data.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/shipping/use-shipping-data.ts
index 43fb4c2ac03..24c68343f07 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/shipping/use-shipping-data.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/shipping/use-shipping-data.ts
@@ -23,6 +23,7 @@ export const useShippingData = (): ShippingData => {
 		needsShipping,
 		hasCalculatedShipping,
 		isLoadingRates,
+		isShippingRatesStale,
 		isCollectable,
 		isSelectingRate,
 	} = useSelect( ( select ) => {
@@ -33,6 +34,7 @@ export const useShippingData = (): ShippingData => {
 			needsShipping: store.getNeedsShipping(),
 			hasCalculatedShipping: store.getHasCalculatedShipping(),
 			isLoadingRates: store.isAddressFieldsForShippingRatesUpdating(),
+			isShippingRatesStale: store.isShippingRatesStale(),
 			isCollectable: rates.every(
 				( { shipping_rates: packageShippingRates } ) =>
 					packageShippingRates.find( ( { method_id: methodId } ) =>
@@ -122,6 +124,7 @@ export const useShippingData = (): ShippingData => {
 		needsShipping,
 		hasCalculatedShipping,
 		isLoadingRates,
+		isShippingRatesStale,
 		isCollectable,
 		hasSelectedLocalPickup,
 	};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/address.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/address.ts
index 3ad429b666d..086a834886a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/address.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/address.ts
@@ -25,6 +25,14 @@ import { decodeEntities } from '@wordpress/html-entities';
 export const addressFieldsForShippingRates: AddressFieldsForShippingRatesType =
 	[ 'state', 'country', 'postcode', 'city' ];

+/**
+ * Checks whether an address field is one the shipping rates depend on.
+ */
+export const isAddressFieldForShippingRates = (
+	field: string
+): field is AddressFieldsForShippingRatesType[ number ] =>
+	( addressFieldsForShippingRates as string[] ).includes( field );
+
 /**
  * Compare two addresses and see if they are the same.
  */
@@ -166,7 +174,7 @@ export const hasAllFieldsForShippingRates = (
 	);

 	const filteredAddressForm = addressFormWithLocale.filter( ( { key } ) =>
-		addressFieldsForShippingRates.includes( key )
+		isAddressFieldForShippingRates( key )
 	);

 	return filteredAddressForm.every( ( { key, hidden, required } ) => {
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/test/address.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/test/address.ts
index 2a7031cd323..7349e33b258 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/test/address.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/test/address.ts
@@ -5,8 +5,24 @@ import {
 	emptyHiddenAddressFields,
 	hasAllFieldsForShippingRates,
 	formatShippingAddress,
+	isAddressFieldForShippingRates,
 } from '@woocommerce/base-utils';

+describe( 'isAddressFieldForShippingRates', () => {
+	it.each( [
+		[ 'country', true ],
+		[ 'state', true ],
+		[ 'city', true ],
+		[ 'postcode', true ],
+		[ 'address_1', false ],
+		[ 'first_name', false ],
+		[ 'email', false ],
+		[ 'namespace/field', false ],
+	] )( 'returns %s -> %s', ( field, expected ) => {
+		expect( isAddressFieldForShippingRates( field ) ).toBe( expected );
+	} );
+} );
+
 describe( 'emptyHiddenAddressFields', () => {
 	it( "Removes state from an address where the country doesn't use states", () => {
 		const address = {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-shipping/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-shipping/block.tsx
index 976ccfeca9f..5c7a58aec6a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-shipping/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-shipping/block.tsx
@@ -6,11 +6,12 @@ import { TotalsShipping } from '@woocommerce/base-components/cart-checkout';
 import { useStoreCart } from '@woocommerce/base-context';
 import { TotalsWrapper } from '@woocommerce/blocks-checkout';
 import { useSelect } from '@wordpress/data';
-import { checkoutStore } from '@woocommerce/block-data';
+import { cartStore, checkoutStore } from '@woocommerce/block-data';
 import {
 	filterShippingRatesByPrefersCollection,
 	hasAllFieldsForShippingRates,
 } from '@woocommerce/base-utils';
+import { Skeleton } from '@woocommerce/base-components/skeleton';

 const Block = ( {
 	className = '',
@@ -19,8 +20,12 @@ const Block = ( {
 } ): JSX.Element | null => {
 	const { cartNeedsShipping, shippingRates, shippingAddress } =
 		useStoreCart();
-	const prefersCollection = useSelect( ( select ) =>
-		select( checkoutStore ).prefersCollection()
+	const { prefersCollection, isShippingRatesStale } = useSelect(
+		( select ) => ( {
+			prefersCollection: select( checkoutStore ).prefersCollection(),
+			isShippingRatesStale: select( cartStore ).isShippingRatesStale(),
+		} ),
+		[]
 	);

 	if ( ! cartNeedsShipping ) {
@@ -33,6 +38,23 @@ const Block = ( {
 	);

 	const hasCompleteAddress = hasAllFieldsForShippingRates( shippingAddress );
+
+	// Rates for a completed address are still on their way, so expect some rather than reporting there are none.
+	const placeholder =
+		hasCompleteAddress && isShippingRatesStale ? (
+			<Skeleton
+				width="45px"
+				height="1em"
+				ariaMessage={ __( 'Loading price…', 'woocommerce' ) }
+			/>
+		) : (
+			<span className="wc-block-components-shipping-placeholder__value">
+				{ hasCompleteAddress
+					? __( 'No available delivery option', 'woocommerce' )
+					: __( 'Enter address to calculate', 'woocommerce' ) }
+			</span>
+		);
+
 	return (
 		<TotalsWrapper className={ className }>
 			<TotalsShipping
@@ -42,19 +64,7 @@ const Block = ( {
 						? __( 'Pickup', 'woocommerce' )
 						: __( 'Delivery', 'woocommerce' )
 				}
-				placeholder={
-					<span className="wc-block-components-shipping-placeholder__value">
-						{ hasCompleteAddress
-							? __(
-									'No available delivery option',
-									'woocommerce'
-								)
-							: __(
-									'Enter address to calculate',
-									'woocommerce'
-								) }
-					</span>
-				}
+				placeholder={ placeholder }
 			/>
 		</TotalsWrapper>
 	);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-shipping/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-shipping/test/block.tsx
new file mode 100644
index 00000000000..050e51bf6c7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-shipping/test/block.tsx
@@ -0,0 +1,84 @@
+/**
+ * External dependencies
+ */
+import { render, within } from '@testing-library/react';
+import { dispatch } from '@wordpress/data';
+import { cartStore } from '@woocommerce/block-data';
+import { SlotFillProvider } from '@woocommerce/blocks-checkout';
+
+/**
+ * Internal dependencies
+ */
+import Block from '../block';
+import { generateShippingPackage } from '../../../../../mocks/shipping-package';
+
+jest.mock( '@wordpress/api-fetch', () => ( {
+	__esModule: true,
+	default: Object.assign( jest.fn(), {
+		use: jest.fn(),
+		setNonce: jest.fn(),
+		setCartHash: jest.fn(),
+	} ),
+} ) );
+
+const noRatesText = 'No available delivery option';
+const skeletonLabel = 'Loading price…';
+
+// Sets a completed address and the empty rates from the last server response.
+const setUpCart = ( { isUpdateQueued }: { isUpdateQueued: boolean } ) => {
+	const cart = dispatch( cartStore );
+	cart.finishResolution( 'getCartData', [] );
+	cart.setCartData( {
+		needsShipping: true,
+		hasCalculatedShipping: true,
+		shippingRates: [
+			generateShippingPackage( { packageId: 0, shippingRates: [] } ),
+		],
+	} );
+	cart.setShippingAddress( {
+		first_name: 'John',
+		last_name: 'Doe',
+		company: '',
+		address_1: '1 Valencia St',
+		address_2: '',
+		city: 'San Francisco',
+		state: 'CA',
+		postcode: '94110',
+		country: 'US',
+		phone: '',
+	} );
+	cart.__internalSetShippingRatesUpdateQueued( isUpdateQueued );
+	// Setting the address starts a real push, so clear its in-flight flag.
+	cart.updatingAddressFieldsForShippingRates( false );
+};
+
+const renderBlock = () =>
+	within(
+		render(
+			<SlotFillProvider>
+				<Block />
+			</SlotFillProvider>
+		).container
+	);
+
+describe( 'Checkout order summary shipping block', () => {
+	it( 'shows a loading placeholder, not "No available delivery option", while rates are stale', () => {
+		setUpCart( { isUpdateQueued: true } );
+
+		const block = renderBlock();
+
+		expect( block.getByLabelText( skeletonLabel ) ).toBeInTheDocument();
+		expect( block.queryByText( noRatesText ) ).not.toBeInTheDocument();
+	} );
+
+	it( 'shows "No available delivery option" once the server has returned no rates', () => {
+		setUpCart( { isUpdateQueued: false } );
+
+		const block = renderBlock();
+
+		expect( block.getByText( noRatesText ) ).toBeInTheDocument();
+		expect(
+			block.queryByLabelText( skeletonLabel )
+		).not.toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx
index bf4d9a75991..8e6201f6b49 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx
@@ -9,6 +9,7 @@ import {
 import { ShippingRatesControl } from '@woocommerce/base-components/cart-checkout';
 import {
 	getShippingRatesPackageCount,
+	getShippingRatesRateCount,
 	hasCollectableRate,
 	hasAllFieldsForShippingRates,
 } from '@woocommerce/base-utils';
@@ -28,6 +29,7 @@ import NoticeBanner from '@woocommerce/base-components/notice-banner';
 import type { ReactElement } from 'react';
 import { useMemo } from '@wordpress/element';
 import ReadMore from '@woocommerce/base-components/read-more';
+import { CheckoutShippingSkeleton } from '@woocommerce/base-components/skeleton/patterns/checkout-shipping';

 /**
  * Renders a shipping rate control option.
@@ -94,6 +96,7 @@ const Block = ( {
 		shippingRates,
 		needsShipping,
 		isLoadingRates,
+		isShippingRatesStale,
 		hasCalculatedShipping,
 		isCollectable,
 	} = useShippingData();
@@ -124,10 +127,27 @@ const Block = ( {
 	const shippingRatesPackageCount =
 		getShippingRatesPackageCount( shippingRates );

+	const addressComplete = hasAllFieldsForShippingRates( shippingAddress );
+
+	// Rates for a completed address are still on their way, so expect some rather than reporting there are none.
+	if (
+		isShippingRatesStale &&
+		addressComplete &&
+		! getShippingRatesRateCount( filteredShippingRates )
+	) {
+		return (
+			<>
+				<StoreNoticesContainer
+					context={ noticeContexts.SHIPPING_METHODS }
+				/>
+				<CheckoutShippingSkeleton />
+			</>
+		);
+	}
+
 	if ( ! hasCalculatedShipping && ! shippingRatesPackageCount ) {
 		return <NoShippingAddressMessage />;
 	}
-	const addressComplete = hasAllFieldsForShippingRates( shippingAddress );

 	return (
 		<>
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/test/block.tsx
new file mode 100644
index 00000000000..d832b575c0d
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/test/block.tsx
@@ -0,0 +1,175 @@
+/**
+ * External dependencies
+ */
+import { act, render, within } from '@testing-library/react';
+import { dispatch } from '@wordpress/data';
+import { cartStore } from '@woocommerce/block-data';
+import { store as noticesStore } from '@wordpress/notices';
+import { noticeContexts } from '@woocommerce/base-context';
+import { SlotFillProvider } from '@woocommerce/blocks-checkout';
+
+/**
+ * Internal dependencies
+ */
+import Block from '../block';
+import { generateShippingPackage } from '../../../../../mocks/shipping-package';
+
+jest.mock( '@wordpress/api-fetch', () => ( {
+	__esModule: true,
+	default: Object.assign( jest.fn(), {
+		use: jest.fn(),
+		setNonce: jest.fn(),
+		setCartHash: jest.fn(),
+	} ),
+} ) );
+
+const completeAddress = {
+	first_name: 'John',
+	last_name: 'Doe',
+	company: '',
+	address_1: '1 Valencia St',
+	address_2: '',
+	city: 'San Francisco',
+	state: 'CA',
+	postcode: '94110',
+	country: 'US',
+	phone: '',
+};
+
+const incompleteAddress = {
+	...completeAddress,
+	city: '',
+	postcode: '',
+};
+
+const warningText =
+	'No shipping options are available for this address. Please verify the address is correct or try a different address.';
+const neutralText = 'Enter a shipping address to view shipping options.';
+const skeletonLabel = 'Loading shipping options…';
+
+// Sets the address the shopper has typed, and the rates from the last server response.
+const setUpCart = ( {
+	shippingAddress,
+	hasCalculatedShipping = true,
+	isUpdateQueued = false,
+	isLoadingRates = false,
+}: {
+	shippingAddress: typeof completeAddress;
+	hasCalculatedShipping?: boolean;
+	isUpdateQueued?: boolean;
+	isLoadingRates?: boolean;
+} ) => {
+	const cart = dispatch( cartStore );
+	cart.setCartData( {
+		needsShipping: true,
+		hasCalculatedShipping,
+		shippingRates: hasCalculatedShipping
+			? [ generateShippingPackage( { packageId: 0, shippingRates: [] } ) ]
+			: [],
+	} );
+	cart.setShippingAddress( shippingAddress );
+	cart.__internalSetShippingRatesUpdateQueued( isUpdateQueued );
+	cart.updatingAddressFieldsForShippingRates( isLoadingRates );
+};
+
+// Queries are scoped to the block because a11y speak repeats notices in a live region outside it.
+const renderBlock = () =>
+	within(
+		render(
+			<SlotFillProvider>
+				<Block />
+			</SlotFillProvider>
+		).container
+	);
+
+describe( 'Checkout shipping methods block', () => {
+	it( 'shows the skeleton, not the no-rates warning, while a completed address is waiting to be sent', () => {
+		setUpCart( {
+			shippingAddress: completeAddress,
+			isUpdateQueued: true,
+		} );
+
+		const block = renderBlock();
+
+		expect( block.getByLabelText( skeletonLabel ) ).toBeInTheDocument();
+		expect( block.queryByText( warningText ) ).not.toBeInTheDocument();
+	} );
+
+	it( 'keeps shipping method notices in the block while showing the skeleton', () => {
+		setUpCart( {
+			shippingAddress: completeAddress,
+			isUpdateQueued: true,
+		} );
+		dispatch( noticesStore ).createErrorNotice( 'Shipping method notice', {
+			id: 'shipping-method-notice',
+			context: noticeContexts.SHIPPING_METHODS,
+		} );
+
+		const block = renderBlock();
+
+		expect( block.getByLabelText( skeletonLabel ) ).toBeInTheDocument();
+		expect(
+			block.getByText( 'Shipping method notice' )
+		).toBeInTheDocument();
+
+		act( () => {
+			dispatch( noticesStore ).removeNotice(
+				'shipping-method-notice',
+				noticeContexts.SHIPPING_METHODS
+			);
+		} );
+	} );
+
+	it( 'shows the no-rates warning once the server has returned no rates for the completed address', () => {
+		setUpCart( {
+			shippingAddress: completeAddress,
+			isUpdateQueued: true,
+		} );
+
+		const block = renderBlock();
+
+		act( () => {
+			dispatch( cartStore ).__internalSetShippingRatesUpdateQueued(
+				false
+			);
+		} );
+
+		expect( block.getByText( warningText ) ).toBeInTheDocument();
+		expect(
+			block.queryByLabelText( skeletonLabel )
+		).not.toBeInTheDocument();
+	} );
+
+	it.each( [
+		[ 'waiting to be sent', { isUpdateQueued: true } ],
+		[ 'being sent', { isLoadingRates: true } ],
+	] )(
+		'shows the skeleton when shipping has not been calculated yet and the completed address is %s',
+		( _, state ) => {
+			setUpCart( {
+				shippingAddress: completeAddress,
+				hasCalculatedShipping: false,
+				...state,
+			} );
+
+			const block = renderBlock();
+
+			expect( block.getByLabelText( skeletonLabel ) ).toBeInTheDocument();
+			expect( block.queryByText( neutralText ) ).not.toBeInTheDocument();
+		}
+	);
+
+	it( 'keeps asking for an address while the address is incomplete, even with changes waiting to be sent', () => {
+		setUpCart( {
+			shippingAddress: incompleteAddress,
+			isUpdateQueued: true,
+		} );
+
+		const block = renderBlock();
+
+		expect( block.getByText( neutralText ) ).toBeInTheDocument();
+		expect(
+			block.queryByLabelText( skeletonLabel )
+		).not.toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/action-types.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/action-types.ts
index 697ac6fbed7..5e26a0fc556 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/action-types.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/action-types.ts
@@ -10,6 +10,7 @@ export const ACTION_TYPES = {
 	UPDATING_CUSTOMER_DATA: 'UPDATING_CUSTOMER_DATA',
 	UPDATING_ADDRESS_FIELDS_FOR_SHIPPING_RATES:
 		'UPDATING_ADDRESS_FIELDS_FOR_SHIPPING_RATES',
+	SET_SHIPPING_RATES_UPDATE_QUEUED: 'SET_SHIPPING_RATES_UPDATE_QUEUED',
 	SET_BILLING_ADDRESS: 'SET_BILLING_ADDRESS',
 	SET_SHIPPING_ADDRESS: 'SET_SHIPPING_ADDRESS',
 	UPDATING_SELECTED_SHIPPING_RATE: 'UPDATING_SELECTED_SHIPPING_RATE',
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/actions.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/actions.ts
index f5fa3903ade..127c6a0ee30 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/actions.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/actions.ts
@@ -140,6 +140,18 @@ export function updatingAddressFieldsForShippingRates( isResolving: boolean ) {
 		isResolving,
 	};
 }
+
+/**
+ * Tracks when shipping address fields that affect the shipping rates have changed locally but have not been sent to
+ * the server yet. Internal: use the isShippingRatesStale selector to read it.
+ */
+export function __internalSetShippingRatesUpdateQueued( isQueued: boolean ) {
+	return {
+		type: types.SET_SHIPPING_RATES_UPDATE_QUEUED,
+		isQueued,
+	};
+}
+
 /**
  * Returns an action object used to track whether the shipping rate is being
  * selected or not.
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/default-state.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/default-state.ts
index 586ab17fbeb..5ce8565e6f5 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/default-state.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/default-state.ts
@@ -97,6 +97,7 @@ export const defaultCartState: CartState = {
 	metaData: {
 		updatingCustomerData: false,
 		updatingAddressFieldsForShippingRates: false,
+		isShippingRatesUpdateQueued: false,
 		updatingSelectedRate: false,
 		applyingCoupon: '',
 		removingCoupon: '',
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 ce7faa02953..74a6ab29378 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
@@ -3,7 +3,7 @@
  */
 import {
 	debounce,
-	addressFieldsForShippingRates,
+	isAddressFieldForShippingRates,
 } from '@woocommerce/base-utils';
 import { CartBillingAddress, CartShippingAddress } from '@woocommerce/types';
 import { select, dispatch } from '@wordpress/data';
@@ -132,6 +132,20 @@ const getDirtyPropsToValidate = (
 	);
 };

+const isShippingRatesUpdateQueued = () =>
+	!! select( cartStore ).getCartMeta().isShippingRatesUpdateQueued;
+
+/**
+ * Updates the queued shipping rates flag, skipping the dispatch when it is unchanged.
+ */
+const setShippingRatesUpdateQueued = ( isQueued: boolean ) => {
+	if ( isShippingRatesUpdateQueued() !== isQueued ) {
+		dispatch( cartStore ).__internalSetShippingRatesUpdateQueued(
+			isQueued
+		);
+	}
+};
+
 /**
  * Function to dispatch an update to the server.
  */
@@ -156,6 +170,7 @@ const updateCustomerData = (): void => {

 	if ( ! needsPush ) {
 		localState.doingPush = false;
+		setShippingRatesUpdateQueued( false );
 		return;
 	}

@@ -173,12 +188,13 @@ const updateCustomerData = (): void => {
 		} )
 	) {
 		localState.doingPush = false;
+		setShippingRatesUpdateQueued( false );
 		return;
 	}

 	const haveAddressFieldsForShippingRatesChanged =
-		localState.dirtyProps.shippingAddress.some( ( field ) =>
-			addressFieldsForShippingRates.includes( field as string )
+		localState.dirtyProps.shippingAddress.some(
+			isAddressFieldForShippingRates
 		);

 	dispatch( cartStore )
@@ -203,6 +219,9 @@ const updateCustomerData = (): void => {
 			localState.doingPush = false;
 			processErrorResponse( response );
 		} );
+
+	// Cleared once the request has started, so the shipping rates loading state takes over without a gap.
+	setShippingRatesUpdateQueued( false );
 };

 /**
@@ -239,6 +258,16 @@ export const pushChanges = ( debounced = true ): void => {
 		return;
 	}

+	if (
+		! isShippingRatesUpdateQueued() &&
+		getDirtyKeys(
+			localState.customerData.shippingAddress,
+			customerData.shippingAddress
+		).some( isAddressFieldForShippingRates )
+	) {
+		setShippingRatesUpdateQueued( true );
+	}
+
 	if ( ! debounced ) {
 		updateCustomerData();
 		return;
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/reducers.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/reducers.ts
index 3740e5fd850..3d27fa9856c 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/reducers.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/reducers.ts
@@ -185,6 +185,15 @@ const reducer: Reducer< CartState > = ( state = defaultCartState, action ) => {
 				},
 			};
 			break;
+		case types.SET_SHIPPING_RATES_UPDATE_QUEUED:
+			state = {
+				...state,
+				metaData: {
+					...state.metaData,
+					isShippingRatesUpdateQueued: !! action.isQueued,
+				},
+			};
+			break;
 		case types.UPDATING_SELECTED_SHIPPING_RATE:
 			state = {
 				...state,
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/selectors.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/selectors.ts
index 4488dcfaa99..3e3178da239 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/selectors.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/selectors.ts
@@ -216,6 +216,20 @@ export const isAddressFieldsForShippingRatesUpdating = (
 	return !! state.metaData.updatingAddressFieldsForShippingRates;
 };

+/**
+ * Retrieves if the shipping rates in the store are out of date because shipping address fields that affect them have
+ * changed. True from the moment the address changes until the server has returned rates for it.
+ *
+ * @param {CartState} state The current state.
+ * @return {boolean} True if the shipping rates do not reflect the current shipping address yet.
+ */
+export const isShippingRatesStale = ( state: CartState ): boolean => {
+	return (
+		!! state.metaData.isShippingRatesUpdateQueued ||
+		!! state.metaData.updatingAddressFieldsForShippingRates
+	);
+};
+
 /**
  * Retrieves if the shipping rate selection is being persisted.
  *
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 d33c9c8fabc..de01100dcee 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
@@ -874,4 +874,103 @@ describe( 'pushChanges', () => {

 		jest.useRealTimers();
 	} );
+
+	describe( 'queued shipping rates updates', () => {
+		const isShippingRatesUpdateQueued = () =>
+			wpDataFunctions.select( cartStore ).getCartMeta()
+				.isShippingRatesUpdateQueued;
+
+		beforeEach( () => {
+			jest.useFakeTimers();
+			updateCustomerDataMock.mockClear();
+			wpDataFunctions
+				.dispatch( cartStore )
+				.__internalSetShippingRatesUpdateQueued( false );
+		} );
+
+		afterEach( () => {
+			jest.useRealTimers();
+		} );
+
+		it( 'Queues a shipping rates update when a rate field changes, until the debounced push sends it', () => {
+			getCustomerDataMock.mockReturnValue( {
+				billingAddress: { ...initialBillingAddress },
+				shippingAddress: { ...initialShippingAddress, city: 'Albany' },
+			} );
+
+			pushChanges();
+
+			expect( updateCustomerDataMock ).not.toHaveBeenCalled();
+			expect( isShippingRatesUpdateQueued() ).toBe( true );
+
+			jest.advanceTimersByTime( 1500 );
+
+			expect( updateCustomerDataMock ).toHaveBeenCalledTimes( 1 );
+			expect( isShippingRatesUpdateQueued() ).toBe( false );
+		} );
+
+		it( 'Does not queue an update for fields that do not affect the shipping rates', () => {
+			getCustomerDataMock.mockReturnValue( {
+				billingAddress: {
+					...initialBillingAddress,
+					city: 'Albany',
+					email: 'jane.doe@mail.com',
+				},
+				shippingAddress: {
+					...initialShippingAddress,
+					address_1: '456 Side St',
+				},
+			} );
+
+			pushChanges();
+
+			expect( isShippingRatesUpdateQueued() ).toBe( false );
+		} );
+
+		it( 'Clears the queued update when the changed address fails validation and is not sent', () => {
+			getValidationErrorMock.mockImplementation( ( key: string ) =>
+				key === 'shipping_postcode'
+					? { message: 'Please enter a valid postcode', hidden: true }
+					: undefined
+			);
+			getCustomerDataMock.mockReturnValue( {
+				billingAddress: { ...initialBillingAddress },
+				shippingAddress: {
+					...initialShippingAddress,
+					postcode: 'INVALID',
+				},
+			} );
+
+			pushChanges();
+
+			expect( isShippingRatesUpdateQueued() ).toBe( true );
+
+			jest.advanceTimersByTime( 1500 );
+
+			expect( updateCustomerDataMock ).not.toHaveBeenCalled();
+			expect( isShippingRatesUpdateQueued() ).toBe( false );
+		} );
+
+		it( 'Clears the queued update when the address is changed back before the push runs', () => {
+			getCustomerDataMock.mockReturnValue( {
+				billingAddress: { ...initialBillingAddress },
+				shippingAddress: { ...initialShippingAddress, city: 'Albany' },
+			} );
+
+			pushChanges();
+
+			expect( isShippingRatesUpdateQueued() ).toBe( true );
+
+			getCustomerDataMock.mockReturnValue( {
+				billingAddress: { ...initialBillingAddress },
+				shippingAddress: { ...initialShippingAddress },
+			} );
+
+			pushChanges();
+			jest.advanceTimersByTime( 1500 );
+
+			expect( updateCustomerDataMock ).not.toHaveBeenCalled();
+			expect( isShippingRatesUpdateQueued() ).toBe( false );
+		} );
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/selectors.js b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/selectors.js
index f38bcd567ef..4bf1e18204a 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/selectors.js
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/selectors.js
@@ -10,6 +10,7 @@ import {
 	getCouponBeingApplied,
 	isRemovingCoupon,
 	getCouponBeingRemoved,
+	isShippingRatesStale,
 } from '../selectors';

 const state = {
@@ -202,3 +203,33 @@ describe( 'getCouponBeingRemoved', () => {
 		);
 	} );
 } );
+
+describe( 'isShippingRatesStale', () => {
+	const withMeta = ( metaData ) => ( {
+		...state,
+		metaData: { ...state.metaData, ...metaData },
+	} );
+
+	it.each( [
+		[ false, false, false ],
+		[ true, false, true ],
+		[ false, true, true ],
+		[ true, true, true ],
+	] )(
+		'is %s queued and %s updating -> %s',
+		(
+			isShippingRatesUpdateQueued,
+			updatingAddressFieldsForShippingRates,
+			expected
+		) => {
+			expect(
+				isShippingRatesStale(
+					withMeta( {
+						isShippingRatesUpdateQueued,
+						updatingAddressFieldsForShippingRates,
+					} )
+				)
+			).toBe( expected );
+		}
+	);
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/cart.ts b/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/cart.ts
index 0569159942f..bfb267b77eb 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/cart.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/types/type-defs/cart.ts
@@ -213,6 +213,7 @@ export interface Cart extends Record< string, unknown > {
 export interface CartMeta {
 	updatingCustomerData: boolean;
 	updatingAddressFieldsForShippingRates: boolean;
+	isShippingRatesUpdateQueued?: boolean;
 	updatingSelectedRate: boolean;
 	isCartDataStale: boolean;
 	applyingCoupon: string;