Commit 6f03de950d1 for woocommerce

commit 6f03de950d1a2a6e4d6c6e80e9c746c82a929ef3
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Sep 11 23:47:08 2026 +0300

    Preserve multi-package shipping selections when one package's request fails (#68142)

    * fix: Batch initial package shipping selections

    Returning to shipping after local pickup mounted each package control
    independently, allowing its initial rate request to miss the shared batch.

    Coordinate Core package selections from their parent mount while preserving
    standalone and extension control behavior.

    Refs #42348

    * chore: Add changelog for package shipping batching

    Document the Checkout fix that coordinates initial multi-package shipping
    rate selections through the existing request batcher.

    Refs #42348

    * fix: Preserve package selections after partial batch failures

    Batched shipping selections can partly succeed. A rejected selection
    restored an entire earlier rates snapshot, overwriting successful sibling
    packages while Core radios still displayed the rejected fallback rate.

    Limit rollback to the packages targeted by the failed request. Derive Core
    radio selection from the cart store so a rejected rate becomes unchecked
    and shoppers can retry it without an automatic retry loop. Preserve the
    standalone controls' default mount behavior.

    Cover mixed batch outcomes, all-package rollback, repeated rejection, and
    successful manual recovery through the real controls, store, and batcher.
    Clarify the initialization tracking and mount-effect comments.

    Refs #42348

    * Rename selectRateOnMount to manageSelectionLocally

    The flag was introduced to let the parent coordinate initial rate
    selection, and its name described only that one effect. It actually
    decides whether the rate list keeps its own selection state at all: it
    gates the mount-time selection, the mirroring of later store changes
    into local state, whether a click updates that state, and which value
    renders as checked.

    A reader reaching for the prop to answer "why is this radio checked?"
    was sent to the wrong place, and the docblock repeated the same partial
    story.

    Rename it to manageSelectionLocally and spell out all four effects in
    the docblock. The prop is unreleased, so no deprecation is needed.

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

    * Extract the shared selected-or-first shipping rate rule

    The parent decides which rate to initialize a package with, and the rate
    list decides which radio renders as checked. Both applied the same rule
    independently: use the rate the store marked selected, else the first
    one. A change to either copy would have made the two disagree, showing
    one rate as checked while the store held another.

    Move the rule into getSelectedOrFirstRateId in base-utils and call it
    from both places, so there is a single definition to change.

    Also record why initial selection is coordinated in the parent at all: a
    multi-package checkout renders every rate list inside a collapsed Panel,
    which does not mount its children until the shopper opens it, so a
    mount-time effect never runs for a closed package.

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

    * Silence no-unnecessary-act warnings in the recovery test

    Two act() wrappers in the recovery test tripped
    testing-library/no-unnecessary-act, because the rule sees only the
    userEvent call inside them.

    They cannot be dropped. Each click starts a store update that settles
    only once the fake timers run, so the click and the timer flush have to
    share one act boundary. Splitting them makes React warn that an update
    was not wrapped in act and the test fails.

    Add focused suppressions with that justification, matching the pattern
    already used in shipping-rates-control-package/test/index.tsx.

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

    * fix: Keep the rate lists owning their selection in the editor

    Moving initial selection to the parent meant the rate lists render the
    cart store's selected rate, and in the block editor that store never
    changes: selectShippingRate returns early when isEditor() is true,
    before the optimistic update. Clicking a shipping rate in the Checkout
    block preview therefore did nothing, where it previously moved the
    radio through the list's own state.

    Pass manageSelectionLocally={ isEditor } so the lists keep owning their
    selection in the editor and the store drives them everywhere else,
    which is the value the frontend already used.

    Refs #42348

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

    * test: Cover re-selection when a package's rates return

    The parent effect forgets a package once its rates disappear, so that a
    later set of rates gets selected again. Nothing exercised that branch:
    the existing empty-package test starts empty, so the package was never
    recorded as initialized and dropping the branch changed nothing.

    Cover the real sequence instead - a package is initialized, an address
    the store cannot ship to empties its rates, and a shippable address
    brings a different rate back.

    Refs #42348

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

    * docs: Correct the rationale for coordinating selection in the parent

    The comment said a multi-package checkout renders every rate list
    inside a collapsed Panel that does not mount its children. Core never
    does: the Checkout block passes collapsible={ false }, and the Cart
    block stopped rendering this control in #58066, so no core path
    collapses the packages.

    State the actual reason - the lists render the store's selection and no
    longer choose one on mount - and keep the collapsed case as the
    secondary benefit it is.

    Refs #42348

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

    * tweak: Describe the shipping selection fix in the changelog entry

    The entry promised that initial multi-package selections would be
    batched when switching from pickup to shipping. They already are on
    trunk, before this branch, so the line described a change no merchant
    could observe while leaving out the one they can.

    Describe the failure path instead: the selections that succeeded are
    kept, and the rejected rate is left unselected so it can be retried.

    Refs #42348

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

    * fix: Stop selecting from the parent in the editor

    Letting the rate lists own their selection in the editor left both them
    and the parent effect calling selectShippingRate there. The cart thunk
    returns early in the editor, so neither call reaches the store or the
    server, but each one still resolves and fires
    set-selected-shipping-rate, so anything listening in the editor saw the
    event twice per package. Measured in the block editor: one firing on
    trunk, two with the previous commit.

    Return from the parent effect when isEditor is true, leaving editor
    selection to the lists the way it was before this branch.

    Refs #42348

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

    ---------

    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/42348-batch-shipping-rate-selection b/plugins/woocommerce/changelog/42348-batch-shipping-rate-selection
new file mode 100644
index 00000000000..3c38449e97e
--- /dev/null
+++ b/plugins/woocommerce/changelog/42348-batch-shipping-rate-selection
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep other packages' shipping selections when one package's selection fails, and leave the rejected rate unselected so the shopper can retry it.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx
index 031b28d05a2..aa9850eceba 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/index.tsx
@@ -29,6 +29,7 @@ export const ShippingRatesControlPackage = ( {
 	collapsible,
 	showItems,
 	highlightChecked = false,
+	manageSelectionLocally = true,
 }: PackageProps ) => {
 	const { selectShippingRate, shippingRates } = useShippingData();
 	const { cartItems } = useStoreCart();
@@ -132,6 +133,7 @@ export const ShippingRatesControlPackage = ( {
 		),
 		renderOption,
 		highlightChecked,
+		manageSelectionLocally,
 	};

 	if ( shouldBeCollapsible ) {
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/package-rates.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/package-rates.tsx
index 530b8b898fe..b0b2cd67d1d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/package-rates.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/package-rates.tsx
@@ -3,6 +3,7 @@
  */
 import { useState, useEffect } from '@wordpress/element';
 import { RadioControl } from '@woocommerce/blocks-components';
+import { getSelectedOrFirstRateId } from '@woocommerce/base-utils';
 import type { CartShippingPackageShippingRate } from '@woocommerce/types';

 /**
@@ -21,6 +22,11 @@ interface PackageRates {
 	disabled?: boolean;
 	// Should the selected rate be highlighted.
 	highlightChecked?: boolean;
+	// Whether this control owns its selection state. When true it selects a rate
+	// on mount, mirrors later store changes into local state, and renders that
+	// local value so clicks show instantly. When false it renders the store's
+	// selected rate and leaves initial selection to the parent.
+	manageSelectionLocally?: boolean;
 }

 const PackageRates = ( {
@@ -32,21 +38,20 @@ const PackageRates = ( {
 	selectedRate,
 	disabled = false,
 	highlightChecked = false,
+	manageSelectionLocally = true,
 }: PackageRates ): JSX.Element => {
 	const selectedRateId = selectedRate?.rate_id;

 	// Store selected rate ID in local state so shipping rates changes are shown in the UI instantly.
 	const [ selectedOption, setSelectedOption ] = useState<
 		string | undefined
-	>( selectedRateId ?? rates[ 0 ]?.rate_id );
+	>( () => getSelectedOrFirstRateId( rates ) );

-	// Update on mount, we do it every time to:
-	// - sync the initial value with the server
-	// - or reset pending request to change shipping rate that might be coming
-	//   from other components (e.g. local pickup), selectShippingRate thunk in
-	//   the cart store properly handles aborting the previous request if needed
+	// Standalone controls synchronize on mount and replace pending selections.
+	// Core disables this effect outside the editor and coordinates initial
+	// selections in the parent.
 	useEffect( () => {
-		if ( selectedOption ) {
+		if ( manageSelectionLocally && selectedOption ) {
 			onSelectRate( selectedOption );
 		}
 		// We want this to run on mount only, beware of updating it as it may cause
@@ -56,28 +61,40 @@ const PackageRates = ( {

 	// Update the selected option if cart state changes in the data store.
 	useEffect( () => {
-		if ( selectedRateId && selectedRateId !== selectedOption ) {
+		if (
+			manageSelectionLocally &&
+			selectedRateId &&
+			selectedRateId !== selectedOption
+		) {
 			setSelectedOption( selectedRateId );
 		}
 		// We want to explicitly react to changes in the data store only here, local state is managed
 		// through different code path.
 		// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [ selectedRateId ] );
+	}, [ selectedRateId, manageSelectionLocally ] );

 	if ( rates.length === 0 ) {
 		return noResultsMessage;
 	}

+	// Local state drives the radio when this control owns the selection, so a
+	// click shows immediately; otherwise the store's selected rate drives it.
+	const checkedRateId = manageSelectionLocally
+		? selectedOption
+		: selectedRateId;
+
 	return (
 		<RadioControl
 			className={ className }
 			onChange={ ( value: string ) => {
-				setSelectedOption( value );
+				if ( manageSelectionLocally ) {
+					setSelectedOption( value );
+				}
 				onSelectRate( value );
 			} }
 			highlightChecked={ highlightChecked }
 			disabled={ disabled }
-			selected={ selectedOption ?? '' }
+			selected={ checkedRateId ?? '' }
 			options={ rates.map( renderOption ) }
 			descriptionStackingDirection="column"
 		/>
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
index bb5f3a33520..0bec8d54253 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
@@ -35,9 +35,10 @@ const testPackageData = generateShippingPackage( {
 } );

 test( 'renders available shipping rates', async () => {
+	const selectShippingRate = jest.fn();
 	( useShippingData as jest.Mock ).mockImplementation( () => {
 		return {
-			selectShippingRate: jest.fn(),
+			selectShippingRate,
 			isSelectingRate: false,
 			shippingRates: [ testPackageData ],
 		};
@@ -70,6 +71,52 @@ test( 'renders available shipping rates', async () => {
 	expect(
 		screen.getByRole( 'radio', { name: 'Flat rate (premium) $15.00' } )
 	).toBeInTheDocument();
+	expect( selectShippingRate ).toHaveBeenCalledTimes( 1 );
+	expect( selectShippingRate ).toHaveBeenCalledWith( 'flat_rate:1', 0 );
+} );
+
+test( 'skips mount selection when disabled but still handles user selection', async () => {
+	const selectShippingRate = jest.fn();
+
+	( useShippingData as jest.Mock ).mockImplementation( () => {
+		return {
+			selectShippingRate,
+			isSelectingRate: false,
+			shippingRates: [ testPackageData ],
+		};
+	} );
+
+	( useStoreCart as jest.Mock ).mockImplementation( () => {
+		return {
+			cartItems: [],
+		};
+	} );
+
+	render(
+		<ShippingRatesControlPackage
+			packageData={ testPackageData }
+			packageId={ testPackageData.package_id }
+			noResultsMessage={
+				<span>No shipping rates available at the moment</span>
+			}
+			manageSelectionLocally={ false }
+		/>
+	);
+
+	expect( selectShippingRate ).not.toHaveBeenCalled();
+
+	// This Jest environment requires an explicit boundary for controlled radio updates.
+	// eslint-disable-next-line testing-library/no-unnecessary-act
+	await act( async () => {
+		await userEvent.click(
+			screen.getByRole( 'radio', {
+				name: 'Flat rate (premium) $15.00',
+			} )
+		);
+	} );
+
+	expect( selectShippingRate ).toHaveBeenCalledTimes( 1 );
+	expect( selectShippingRate ).toHaveBeenCalledWith( 'flat_rate:2', 0 );
 } );

 test( 'changes rate selection locally and informs API about it', async () => {
@@ -217,3 +264,47 @@ test( 'upstream rate selection updates are properly reflected in local state', a
 	expect( firstRate ).toBeChecked();
 	expect( secondRate ).not.toBeChecked();
 } );
+
+test( 'Core clears a rejected selection so the shopper can retry it', async () => {
+	const selectShippingRate = jest.fn();
+	( useShippingData as jest.Mock ).mockReturnValue( {
+		selectShippingRate,
+		shippingRates: [ testPackageData ],
+	} );
+	( useStoreCart as jest.Mock ).mockReturnValue( { cartItems: [] } );
+	const selectedPackage = {
+		...testPackageData,
+		shipping_rates: testPackageData.shipping_rates.map(
+			( rate, index ) => ( {
+				...rate,
+				selected: index === 0,
+			} )
+		),
+	};
+	const { rerender } = render(
+		<ShippingRatesControlPackage
+			packageData={ selectedPackage }
+			packageId={ 0 }
+			manageSelectionLocally={ false }
+			noResultsMessage={ <span>No rates</span> }
+		/>
+	);
+	const flatRate = screen.getByRole( 'radio', { name: 'Flat rate $10.00' } );
+	expect( flatRate ).toBeChecked();
+
+	// Pickup is filtered out of Shipping, leaving no visible selected rate after rollback.
+	rerender(
+		<ShippingRatesControlPackage
+			packageData={ testPackageData }
+			packageId={ 0 }
+			manageSelectionLocally={ false }
+			noResultsMessage={ <span>No rates</span> }
+		/>
+	);
+	expect( flatRate ).not.toBeChecked();
+	expect( selectShippingRate ).not.toHaveBeenCalled();
+
+	await userEvent.click( flatRate );
+	expect( selectShippingRate ).toHaveBeenCalledTimes( 1 );
+	expect( selectShippingRate ).toHaveBeenCalledWith( 'flat_rate:1', 0 );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/types.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/types.ts
index 33aa2f55d13..f5a7189066d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/types.ts
@@ -48,4 +48,9 @@ export interface PackageProps {
 	showItems?: TernaryFlag;
 	// Should the selected rate be highlighted.
 	highlightChecked?: boolean;
+	// Whether this control owns its selection state. When true it selects a rate
+	// on mount, mirrors later store changes into local state, and renders that
+	// local value so clicks show instantly. When false it renders the store's
+	// selected rate and leaves initial selection to the parent.
+	manageSelectionLocally?: boolean;
 }
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 817d8c05baa..cfddefe2e81 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
@@ -2,11 +2,12 @@
  * External dependencies
  */
 import { __ } from '@wordpress/i18n';
-import { useEffect } from '@wordpress/element';
+import { useEffect, useRef } from '@wordpress/element';
 import { usePrevious } from '@woocommerce/base-hooks';
 import LoadingMask from '@woocommerce/base-components/loading-mask';
 import { ExperimentalOrderShippingPackages } from '@woocommerce/blocks-checkout';
 import {
+	getSelectedOrFirstRateId,
 	getShippingRatesPackageCount,
 	getShippingRatesRateCount,
 } from '@woocommerce/base-utils';
@@ -37,6 +38,56 @@ const Packages = ( {
 	renderOption,
 	context = '',
 }: PackagesProps ): JSX.Element | null => {
+	const { selectShippingRate } = useShippingData();
+	// In the editor the cart store never changes (the thunk returns early), so the rate
+	// lists have to keep owning their selection or clicking a rate would do nothing.
+	const { isEditor } = useEditorContext();
+	// Attempt initialization once; failed rates remain unchecked so shoppers can retry them.
+	const initializedPackageIds = useRef< Set< string | number > >( new Set() );
+
+	// Initial selection is coordinated here because the rate lists render the store's
+	// selected rate rather than their own state, so they no longer choose one on mount.
+	// Running it from the parent also reaches packages whose list is not mounted, such
+	// as a consumer that renders them in collapsed panels.
+	useEffect( () => {
+		// The rate lists own their selection in the editor, so leave it to them.
+		if ( isEditor ) {
+			return;
+		}
+
+		const currentPackageIds = new Set(
+			packages.map( ( shippingPackage ) => shippingPackage.package_id )
+		);
+
+		initializedPackageIds.current.forEach( ( packageId ) => {
+			if ( ! currentPackageIds.has( packageId ) ) {
+				initializedPackageIds.current.delete( packageId );
+			}
+		} );
+
+		packages.forEach( ( shippingPackage ) => {
+			const rateId = getSelectedOrFirstRateId(
+				shippingPackage.shipping_rates
+			);
+
+			if ( ! rateId ) {
+				initializedPackageIds.current.delete(
+					shippingPackage.package_id
+				);
+				return;
+			}
+
+			if (
+				initializedPackageIds.current.has( shippingPackage.package_id )
+			) {
+				return;
+			}
+
+			initializedPackageIds.current.add( shippingPackage.package_id );
+			selectShippingRate( rateId, shippingPackage.package_id );
+		} );
+	}, [ packages, selectShippingRate, isEditor ] );
+
 	// If there are no packages, return nothing.
 	if ( ! packages.length ) {
 		return null;
@@ -53,6 +104,7 @@ const Packages = ( {
 					showItems={ showItems }
 					noResultsMessage={ noResultsMessage }
 					renderOption={ renderOption }
+					manageSelectionLocally={ isEditor }
 				/>
 			) ) }
 		</>
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/recovery.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/recovery.tsx
new file mode 100644
index 00000000000..f19e1e520af
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/recovery.tsx
@@ -0,0 +1,157 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import apiFetch from '@wordpress/api-fetch';
+import { createRegistry, RegistryProvider } from '@wordpress/data';
+import { cartStore } from '@woocommerce/block-data';
+import { SlotFillProvider } from '@woocommerce/blocks-checkout';
+import type { CartShippingPackageShippingRate } from '@woocommerce/types';
+import { useShippingData } from '@woocommerce/base-context';
+
+/**
+ * Internal dependencies
+ */
+import ShippingRatesControl from '..';
+import {
+	generateShippingPackage,
+	generateShippingRate,
+} 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 ShippingOptions = () => {
+	const { shippingRates } = useShippingData();
+	return (
+		<ShippingRatesControl
+			shippingRates={ shippingRates.map( ( shippingPackage ) => ( {
+				...shippingPackage,
+				shipping_rates: shippingPackage.shipping_rates.filter(
+					( rate ) => rate.method_id !== 'pickup_location'
+				),
+			} ) ) }
+			isLoadingRates={ false }
+			context="woocommerce/checkout"
+		/>
+	);
+};
+
+const createPackage = ( selectedRate: string ) =>
+	generateShippingPackage( {
+		packageId: 0,
+		shippingRates: [
+			generateShippingRate( {
+				rateId: 'flat_rate:1',
+				name: 'Flat rate',
+				price: '1250',
+				instanceID: 1,
+				selected: selectedRate === 'flat_rate:1',
+			} ),
+			generateShippingRate( {
+				rateId: 'pickup_location:0',
+				name: 'Pickup location',
+				price: '0',
+				instanceID: 0,
+				selected: selectedRate === 'pickup_location:0',
+			} ),
+		],
+	} );
+
+test( 'failed initialization stays retryable without automatically repeating rejected requests', async () => {
+	jest.useFakeTimers();
+	const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
+	const registry = createRegistry();
+	registry.register( cartStore );
+	const dispatch = registry.dispatch( cartStore );
+	const select = registry.select( cartStore );
+	dispatch.finishResolution( 'getCartData' );
+	dispatch.setCartData( {
+		shippingRates: [ createPackage( 'pickup_location:0' ) ],
+	} );
+	jest.mocked( apiFetch ).mockResolvedValue( {
+		responses: [
+			{
+				status: 400,
+				headers: {},
+				body: {
+					code: 'woocommerce_rest_cart_shipping_rate_invalid',
+					message: 'Shipping rate rejected.',
+					data: { status: 400 },
+				},
+			},
+		],
+	} );
+
+	const { unmount } = render(
+		<RegistryProvider value={ registry }>
+			<SlotFillProvider>
+				<ShippingOptions />
+			</SlotFillProvider>
+		</RegistryProvider>
+	);
+	try {
+		await act( async () => {
+			await jest.runOnlyPendingTimersAsync();
+		} );
+		const flatRate = screen.getByRole( 'radio', {
+			name: 'Flat rate $12.50',
+		} );
+		expect( flatRate ).not.toBeChecked();
+		expect( apiFetch ).toHaveBeenCalledTimes( 1 );
+
+		// Give a render-driven retry loop enough time to reveal itself.
+		await act( async () => {
+			await jest.advanceTimersByTimeAsync( 3000 );
+		} );
+		expect( apiFetch ).toHaveBeenCalledTimes( 1 );
+
+		// The click starts a store update that only settles once the fake timers
+		// run, so both have to happen inside the same act boundary.
+		// eslint-disable-next-line testing-library/no-unnecessary-act
+		await act( async () => {
+			await user.click( flatRate );
+			await jest.runOnlyPendingTimersAsync();
+		} );
+		expect( flatRate ).not.toBeChecked();
+		expect( apiFetch ).toHaveBeenCalledTimes( 2 );
+
+		jest.mocked( apiFetch ).mockResolvedValueOnce( {
+			responses: [
+				{
+					status: 200,
+					headers: {},
+					body: {
+						shipping_rates: [ createPackage( 'flat_rate:1' ) ],
+					},
+				},
+			],
+		} );
+		// The click starts a store update that only settles once the fake timers
+		// run, so both have to happen inside the same act boundary.
+		// eslint-disable-next-line testing-library/no-unnecessary-act
+		await act( async () => {
+			await user.click( flatRate );
+			await jest.runOnlyPendingTimersAsync();
+		} );
+		expect( flatRate ).toBeChecked();
+		expect(
+			select
+				.getShippingRates()[ 0 ]
+				.shipping_rates.find(
+					( rate: CartShippingPackageShippingRate ) => rate.selected
+				)?.rate_id
+		).toBe( 'flat_rate:1' );
+		expect( apiFetch ).toHaveBeenCalledTimes( 3 );
+	} finally {
+		unmount();
+		jest.useRealTimers();
+	}
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx
index 009ad8bd99e..0f6fde8574f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx
@@ -2,12 +2,21 @@
  * External dependencies
  */
 import { render, screen } from '@testing-library/react';
-import { useStoreCart } from '@woocommerce/base-context';
+import {
+	useEditorContext,
+	useShippingData,
+	useStoreCart,
+} from '@woocommerce/base-context';

 /**
  * Internal dependencies
  */
 import ShippingRatesControl from '..';
+import type { ShippingRatesControlProps } from '../types';
+import {
+	generateShippingPackage,
+	generateShippingRate,
+} from '../../../../../mocks/shipping-package';

 jest.mock( '@woocommerce/base-context', () => ( {
 	useStoreCart: jest.fn(),
@@ -18,11 +27,26 @@ jest.mock( '@woocommerce/base-context', () => ( {
 	} ) ),
 } ) );

+const mockShippingRatesControlPackage = jest.fn(
+	( props: Record< string, unknown > ) => {
+		void props;
+		return <div data-testid="shipping-package" />;
+	}
+);
+jest.mock( '../../shipping-rates-control-package', () => ( {
+	__esModule: true,
+	default: ( props: Record< string, unknown > ) =>
+		mockShippingRatesControlPackage( props ),
+} ) );
+
 jest.mock( '@woocommerce/base-hooks', () => ( {
 	usePrevious: jest.fn(),
 } ) );

-const mockSlotRender = jest.fn( () => <div data-testid="shipping-slot" /> );
+const mockSlotRender = jest.fn( ( props: Record< string, unknown > ) => {
+	void props;
+	return <div data-testid="shipping-slot" />;
+} );
 jest.mock( '@woocommerce/blocks-checkout', () => {
 	const MockFill = ( { children }: { children: React.ReactNode } ) => (
 		<>{ children }</>
@@ -32,7 +56,7 @@ jest.mock( '@woocommerce/blocks-checkout', () => {
 	return { ExperimentalOrderShippingPackages: MockFill };
 } );

-const defaultProps = {
+const defaultProps: ShippingRatesControlProps = {
 	shippingRates: [],
 	isLoadingRates: false,
 	className: 'test-class',
@@ -43,6 +67,24 @@ const defaultProps = {
 	context: 'woocommerce/checkout',
 };

+const createShippingPackage = (
+	packageId: string | number,
+	rateId: string
+) => ( {
+	...generateShippingPackage( {
+		packageId: 0,
+		shippingRates: [
+			generateShippingRate( {
+				rateId,
+				name: 'Flat rate',
+				price: '1000',
+				instanceID: 1,
+			} ),
+		],
+	} ),
+	package_id: packageId,
+} );
+
 describe( 'ShippingRatesControl slot rendering', () => {
 	beforeEach( () => {
 		jest.clearAllMocks();
@@ -84,4 +126,318 @@ describe( 'ShippingRatesControl slot rendering', () => {
 		).not.toBeInTheDocument();
 		expect( mockSlotRender ).not.toHaveBeenCalled();
 	} );
+
+	it( 'selects initial rates for all packages when rendering them', () => {
+		const selectShippingRate = jest.fn();
+		( useShippingData as jest.Mock ).mockReturnValue( {
+			hasSelectedLocalPickup: false,
+			selectedRates: {},
+			selectShippingRate,
+		} );
+		const shippingRates = [
+			generateShippingPackage( {
+				packageId: 0,
+				shippingRates: [
+					generateShippingRate( {
+						rateId: 'flat_rate:1',
+						name: 'Flat rate',
+						price: '1000',
+						instanceID: 1,
+					} ),
+				],
+			} ),
+			generateShippingPackage( {
+				packageId: 1,
+				shippingRates: [
+					generateShippingRate( {
+						rateId: 'free_shipping:2',
+						name: 'Free shipping',
+						price: '0',
+						instanceID: 2,
+					} ),
+					generateShippingRate( {
+						rateId: 'flat_rate:3',
+						name: 'Flat rate',
+						price: '1000',
+						instanceID: 3,
+						selected: true,
+					} ),
+				],
+			} ),
+			generateShippingPackage( {
+				packageId: 2,
+				shippingRates: [
+					generateShippingRate( {
+						rateId: 'flat_rate:4',
+						name: 'Flat rate',
+						price: '1000',
+						instanceID: 4,
+					} ),
+				],
+			} ),
+			generateShippingPackage( {
+				packageId: 3,
+				shippingRates: [],
+			} ),
+		];
+
+		render(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ shippingRates }
+			/>
+		);
+
+		expect( selectShippingRate.mock.calls ).toEqual( [
+			[ 'flat_rate:1', 0 ],
+			[ 'flat_rate:3', 1 ],
+			[ 'flat_rate:4', 2 ],
+		] );
+		expect( screen.getAllByTestId( 'shipping-package' ) ).toHaveLength( 4 );
+		expect( mockShippingRatesControlPackage ).toHaveBeenCalledTimes( 4 );
+		mockShippingRatesControlPackage.mock.calls.forEach( ( [ props ] ) => {
+			expect( props ).toEqual(
+				expect.objectContaining( { manageSelectionLocally: false } )
+			);
+		} );
+	} );
+
+	it( 'selects initial rates when packages become available after an empty render', () => {
+		const selectShippingRate = jest.fn();
+		( useShippingData as jest.Mock ).mockReturnValue( {
+			hasSelectedLocalPickup: false,
+			selectedRates: {},
+			selectShippingRate,
+		} );
+		const firstPackage = createShippingPackage( 0, 'flat_rate:1' );
+		const secondPackage = createShippingPackage(
+			'subscription-package',
+			'flat_rate:2'
+		);
+		const { rerender } = render(
+			<ShippingRatesControl { ...defaultProps } shippingRates={ [] } />
+		);
+
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ firstPackage, secondPackage ] }
+			/>
+		);
+
+		expect( selectShippingRate.mock.calls ).toEqual( [
+			[ 'flat_rate:1', 0 ],
+			[ 'flat_rate:2', 'subscription-package' ],
+		] );
+	} );
+
+	it( 'selects an initial rate when an existing empty package becomes populated', () => {
+		const selectShippingRate = jest.fn();
+		( useShippingData as jest.Mock ).mockReturnValue( {
+			hasSelectedLocalPickup: false,
+			selectedRates: {},
+			selectShippingRate,
+		} );
+		const emptyPackage = generateShippingPackage( {
+			packageId: 0,
+			shippingRates: [],
+		} );
+		const populatedPackage = createShippingPackage( 0, 'flat_rate:1' );
+		const { rerender } = render(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ emptyPackage ] }
+			/>
+		);
+
+		expect( selectShippingRate ).not.toHaveBeenCalled();
+
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ populatedPackage ] }
+			/>
+		);
+
+		expect( selectShippingRate.mock.calls ).toEqual( [
+			[ 'flat_rate:1', 0 ],
+		] );
+
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ createShippingPackage( 0, 'flat_rate:1' ) ] }
+			/>
+		);
+
+		expect( selectShippingRate ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'selects only newly added packages on subsequent renders', () => {
+		const selectShippingRate = jest.fn();
+		( useShippingData as jest.Mock ).mockReturnValue( {
+			hasSelectedLocalPickup: false,
+			selectedRates: {},
+			selectShippingRate,
+		} );
+		const firstPackage = createShippingPackage( 0, 'flat_rate:1' );
+		const secondPackage = createShippingPackage( 1, 'flat_rate:2' );
+		const { rerender } = render(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ firstPackage ] }
+			/>
+		);
+		selectShippingRate.mockClear();
+
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ firstPackage, secondPackage ] }
+			/>
+		);
+
+		expect( selectShippingRate.mock.calls ).toEqual( [
+			[ 'flat_rate:2', 1 ],
+		] );
+
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ firstPackage, secondPackage ] }
+			/>
+		);
+
+		expect( selectShippingRate ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'selects a package again after it is removed and re-added', () => {
+		const selectShippingRate = jest.fn();
+		( useShippingData as jest.Mock ).mockReturnValue( {
+			hasSelectedLocalPickup: false,
+			selectedRates: {},
+			selectShippingRate,
+		} );
+		const firstPackage = createShippingPackage( 0, 'flat_rate:1' );
+		const secondPackage = createShippingPackage( 1, 'flat_rate:2' );
+		const { rerender } = render(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ firstPackage, secondPackage ] }
+			/>
+		);
+		selectShippingRate.mockClear();
+
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ firstPackage ] }
+			/>
+		);
+		expect( selectShippingRate ).not.toHaveBeenCalled();
+
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ firstPackage, secondPackage ] }
+			/>
+		);
+
+		expect( selectShippingRate.mock.calls ).toEqual( [
+			[ 'flat_rate:2', 1 ],
+		] );
+	} );
+
+	it( 'selects again for a package whose rates disappear and come back', () => {
+		const selectShippingRate = jest.fn();
+		( useShippingData as jest.Mock ).mockReturnValue( {
+			hasSelectedLocalPickup: false,
+			selectedRates: {},
+			selectShippingRate,
+		} );
+		const { rerender } = render(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ createShippingPackage( 0, 'flat_rate:1' ) ] }
+			/>
+		);
+
+		expect( selectShippingRate.mock.calls ).toEqual( [
+			[ 'flat_rate:1', 0 ],
+		] );
+
+		// An address the store cannot ship to leaves the package without rates.
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [
+					generateShippingPackage( {
+						packageId: 0,
+						shippingRates: [],
+					} ),
+				] }
+			/>
+		);
+
+		// A shippable address brings different rates back, none of them selected.
+		rerender(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ createShippingPackage( 0, 'flat_rate:9' ) ] }
+			/>
+		);
+
+		expect( selectShippingRate.mock.calls ).toEqual( [
+			[ 'flat_rate:1', 0 ],
+			[ 'flat_rate:9', 0 ],
+		] );
+	} );
+
+	it( 'lets the rate lists own their selection in the editor only', () => {
+		const selectShippingRate = jest.fn();
+		( useShippingData as jest.Mock ).mockReturnValue( {
+			hasSelectedLocalPickup: false,
+			selectedRates: {},
+			selectShippingRate,
+		} );
+
+		render(
+			<ShippingRatesControl
+				{ ...defaultProps }
+				shippingRates={ [ createShippingPackage( 0, 'flat_rate:1' ) ] }
+			/>
+		);
+
+		expect( mockShippingRatesControlPackage ).toHaveBeenCalledWith(
+			expect.objectContaining( { manageSelectionLocally: false } )
+		);
+
+		// The cart store never changes in the editor, because the selectShippingRate
+		// thunk returns early there, so the rate list has to render its own state or
+		// clicking a rate in the block preview would do nothing.
+		mockShippingRatesControlPackage.mockClear();
+		selectShippingRate.mockClear();
+		( useEditorContext as jest.Mock ).mockReturnValue( { isEditor: true } );
+		try {
+			render(
+				<ShippingRatesControl
+					{ ...defaultProps }
+					shippingRates={ [
+						createShippingPackage( 0, 'flat_rate:1' ),
+					] }
+				/>
+			);
+
+			expect( mockShippingRatesControlPackage ).toHaveBeenCalledWith(
+				expect.objectContaining( { manageSelectionLocally: true } )
+			);
+			// Selecting from here as well would fire set-selected-shipping-rate
+			// twice per package for anything listening in the editor.
+			expect( selectShippingRate ).not.toHaveBeenCalled();
+		} finally {
+			( useEditorContext as jest.Mock ).mockReturnValue( {
+				isEditor: false,
+			} );
+		}
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/shipping-rates.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/shipping-rates.ts
index 7e1ebfcadaa..aa7d27c2aa1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/shipping-rates.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/shipping-rates.ts
@@ -76,6 +76,18 @@ export const hasShippingRate = (
 	);
 };

+/**
+ * Get the rate a package should show as selected: the rate the store has
+ * selected, falling back to the first available one when it has none yet.
+ *
+ * The parent initializes packages with this rate and the rate list renders it,
+ * so both have to apply the same rule to agree on what is checked.
+ */
+export const getSelectedOrFirstRateId = (
+	rates: CartShippingPackageShippingRate[]
+): string | undefined =>
+	rates.find( ( rate ) => rate.selected )?.rate_id ?? rates[ 0 ]?.rate_id;
+
 export const hasSelectedShippingRate = (
 	shippingRates: CartShippingRate[]
 ): boolean => {
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/select-shipping-rate.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/select-shipping-rate.ts
new file mode 100644
index 00000000000..1433995a762
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/select-shipping-rate.ts
@@ -0,0 +1,183 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { createRegistry } from '@wordpress/data';
+import type { CartShippingRate } from '@woocommerce/types';
+
+/**
+ * Internal dependencies
+ */
+import { store as cartStore } from '..';
+import {
+	generateShippingPackage,
+	generateShippingRate,
+} from '../../../../../assets/js/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 createPackages = ( selectedRateIds: string[] ) =>
+	selectedRateIds.map( ( selectedRateId, packageId ) =>
+		generateShippingPackage( {
+			packageId,
+			shippingRates: [
+				generateShippingRate( {
+					rateId: 'flat_rate:1',
+					name: 'Flat rate',
+					price: '1250',
+					instanceID: 1,
+					selected: selectedRateId === 'flat_rate:1',
+				} ),
+				generateShippingRate( {
+					rateId: 'pickup_location:0',
+					name: 'Pickup location',
+					price: '0',
+					instanceID: 0,
+					selected: selectedRateId === 'pickup_location:0',
+				} ),
+			],
+		} )
+	);
+
+const rejectedSelection = {
+	code: 'woocommerce_rest_cart_shipping_rate_invalid',
+	message: 'Shipping rate rejected.',
+	data: { status: 400 },
+};
+
+describe( 'batched shipping rate selection', () => {
+	beforeEach( () => {
+		jest.useFakeTimers();
+		jest.mocked( apiFetch ).mockReset();
+	} );
+
+	afterEach( () => {
+		jest.useRealTimers();
+	} );
+
+	it( 'preserves successful packages when the middle selection fails', async () => {
+		const registry = createRegistry();
+		registry.register( cartStore );
+		const dispatch = registry.dispatch( cartStore );
+		const select = registry.select( cartStore );
+		dispatch.finishResolution( 'getCartData' );
+		dispatch.setCartData( {
+			shippingRates: createPackages( [
+				'pickup_location:0',
+				'pickup_location:0',
+				'pickup_location:0',
+			] ),
+		} );
+		jest.mocked( apiFetch ).mockResolvedValueOnce( {
+			responses: [
+				{
+					status: 200,
+					headers: {},
+					body: {
+						shipping_rates: createPackages( [
+							'flat_rate:1',
+							'pickup_location:0',
+							'pickup_location:0',
+						] ),
+					},
+				},
+				{ status: 400, headers: {}, body: rejectedSelection },
+				{
+					status: 200,
+					headers: {},
+					body: {
+						shipping_rates: createPackages( [
+							'flat_rate:1',
+							'pickup_location:0',
+							'flat_rate:1',
+						] ),
+						totals: { total_shipping: '2500', total_price: '5500' },
+					},
+				},
+			],
+		} );
+
+		const selections = Promise.allSettled(
+			[ 0, 1, 2 ].map( ( packageId ) =>
+				dispatch.selectShippingRate( 'flat_rate:1', packageId )
+			)
+		);
+		await jest.runOnlyPendingTimersAsync();
+		const results = await selections;
+
+		expect( results.map( ( result ) => result.status ) ).toEqual( [
+			'fulfilled',
+			'rejected',
+			'fulfilled',
+		] );
+		expect( apiFetch ).toHaveBeenCalledTimes( 1 );
+		expect( apiFetch ).toHaveBeenCalledWith(
+			expect.objectContaining( {
+				path: '/wc/store/v1/batch',
+				data: {
+					requests: [ 0, 1, 2 ].map( ( packageId ) =>
+						expect.objectContaining( {
+							path: '/wc/store/v1/cart/select-shipping-rate',
+							body: {
+								package_id: packageId,
+								rate_id: 'flat_rate:1',
+							},
+						} )
+					),
+				},
+			} )
+		);
+		expect(
+			select
+				.getShippingRates()
+				.map(
+					( shippingPackage: CartShippingRate ) =>
+						shippingPackage.shipping_rates.find(
+							( rate ) => rate.selected
+						)?.rate_id
+				)
+		).toEqual( [ 'flat_rate:1', 'pickup_location:0', 'flat_rate:1' ] );
+		expect( select.getCartTotals() ).toMatchObject( {
+			total_shipping: '2500',
+			total_price: '5500',
+		} );
+		expect( select.isShippingRateBeingSelected() ).toBe( false );
+		expect( select.getCartErrors() ).toContainEqual( rejectedSelection );
+	} );
+	it( 'restores all packages when a shared pickup selection fails', async () => {
+		const registry = createRegistry();
+		registry.register( cartStore );
+		const dispatch = registry.dispatch( cartStore );
+		const select = registry.select( cartStore );
+		dispatch.finishResolution( 'getCartData' );
+		const originalRates = createPackages( [
+			'flat_rate:1',
+			'flat_rate:1',
+			'flat_rate:1',
+		] );
+		dispatch.setCartData( { shippingRates: originalRates } );
+		jest.mocked( apiFetch ).mockResolvedValueOnce( {
+			responses: [
+				{ status: 400, headers: {}, body: rejectedSelection },
+			],
+		} );
+
+		const selections = Promise.allSettled( [
+			dispatch.selectShippingRate( 'pickup_location:0', null ),
+		] );
+		await jest.runOnlyPendingTimersAsync();
+		expect( await selections ).toEqual( [
+			{ status: 'rejected', reason: rejectedSelection },
+		] );
+
+		expect( select.getShippingRates() ).toEqual( originalRates );
+		expect( select.isShippingRateBeingSelected() ).toBe( false );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/thunks.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/thunks.ts
index b1ed802f235..9a0a4902e2c 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/thunks.ts
@@ -694,7 +694,7 @@ export const selectShippingRate =
 			return;
 		}

-		const previousRates = select.getShippingRates();
+		const previousRates: CartShippingRate[] = select.getShippingRates();

 		try {
 			dispatch.shippingRatesBeingSelected( true );
@@ -749,10 +749,25 @@ export const selectShippingRate =
 			dispatch.shippingRatesBeingSelected( false );
 			return response;
 		} catch ( error ) {
-			// Roll back the optimistic update so the UI reflects the server's
-			// actual selection rather than a rate the server never committed.
+			// Roll back only this request's packages; other selections in the batch may have succeeded.
 			dispatch.setCartData( {
-				shippingRates: previousRates,
+				shippingRates: select
+					.getShippingRates()
+					.map( ( pkg: CartShippingRate ) => {
+						if (
+							packageId !== null &&
+							pkg.package_id !== packageId
+						) {
+							return pkg;
+						}
+						return (
+							previousRates.find(
+								( previousPackage ) =>
+									previousPackage.package_id ===
+									pkg.package_id
+							) ?? pkg
+						);
+					} ),
 			} );
 			dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
 			dispatch.shippingRatesBeingSelected( false );