Commit a64114f02a8 for woocommerce

commit a64114f02a85f18082e1dbcd1140ef7601a94e9d
Author: Mike Jolley <mike.jolley@me.com>
Date:   Thu Apr 30 13:40:07 2026 +0100

    Fix highest shipping cost shown before address entry with Local Pickup (#64092)

    * Fix highest shipping cost shown before address entry with Local Pickup enabled

    * Add changelog entry for shipping cost fix

    * Use prefersCollection directly for totals label to prevent label flicker on toggle

    * Show first rate name in totals immediately on toggle instead of generic label

    * Optimistically update selected shipping rate in store for instant UI feedback

    * Update changelog to cover optimistic UI improvement

    * Simplify shipping rate optimistic update types and comments

    * fix: only fallback to rate name when exactly one option available

    During Ship/Pickup toggles when no rate is selected, the fallback
    now only shows a specific rate name if there is exactly one available
    shipping option. When multiple options exist, the generic 'Shipping'
    label is shown instead of misleadingly displaying one arbitrary method.

    Co-Authored-By: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>

    * Add changefile(s) from automation for the following project(s): woocommerce

    * chore: remove manual changelog in favour of CI-generated one

    Co-Authored-By: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>

    * Address review feedback: fix filter docblock and remove unused import

    - Restore canonical @since 3.2.0 and @param tags on the duplicated
      woocommerce_shipping_chosen_method filter docblock (was incorrectly
      marked @since 10.8.0 with a self-referential pointer)
    - Remove unused CartCheckoutUtils import from default shipping method test

    Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>

    * Disable Squiz LongConditionClosingComment.Missing sniff project-wide

    The sniff requires `//end if` / `//end foreach` comments on closing
    braces of long conditional/loop blocks. It's a legacy WP convention
    that adds noise without improving readability in modern editors, and
    `phpcs-changed` line-mapping makes it noisy on diff-based linting.

    Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>

    ---------

    Co-authored-by: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>
    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64092-wooplug-4230-highest-shipping-cost-shown-before-en b/plugins/woocommerce/changelog/64092-wooplug-4230-highest-shipping-cost-shown-before-en
new file mode 100644
index 00000000000..e6a77a4b907
--- /dev/null
+++ b/plugins/woocommerce/changelog/64092-wooplug-4230-highest-shipping-cost-shown-before-en
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Fix highest shipping cost shown before address entry when using Local Pickup with block checkout. Also improves shipping rate selection UX with optimistic UI updates in the order totals.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/shipping/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/shipping/index.tsx
index 01ec60f60e8..12ce5b7246c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/shipping/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/shipping/index.tsx
@@ -4,6 +4,7 @@
 import { __ } from '@wordpress/i18n';
 import { TotalsItem } from '@woocommerce/blocks-components';
 import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
+import type { CartShippingRate } from '@woocommerce/types';
 import {
 	hasSelectedShippingRate,
 	getSelectedShippingRateNames,
@@ -24,20 +25,39 @@ export interface TotalShippingProps {
 	label?: string;
 	placeholder?: React.ReactNode;
 	collaterals?: React.ReactNode;
+	shippingRates?: CartShippingRate[];
 }

 export const TotalsShipping = ( {
 	label = __( 'Shipping', 'woocommerce' ),
 	placeholder = null,
 	collaterals = null,
+	shippingRates: shippingRatesProp,
 }: TotalShippingProps ): JSX.Element | null => {
-	const { cartTotals, shippingRates } = useStoreCart();
+	const { cartTotals, shippingRates: cartShippingRates } = useStoreCart();
 	const { isLoading } = useOrderSummaryLoadingState();
+	const shippingRates = shippingRatesProp ?? cartShippingRates;
 	const hasSelectedRates = hasSelectedShippingRate( shippingRates );
-	const rateNames = getSelectedShippingRateNames( shippingRates );
-	const hasMultipleRates = rateNames.length > 1;
+
+	// Fall back to the first available rate name only when there is exactly one
+	// available option; otherwise keep the generic label until selection settles.
+	const selectedNames = getSelectedShippingRateNames( shippingRates );
+	const availableRateNames = shippingRates.flatMap( ( shippingPackage ) =>
+		shippingPackage.shipping_rates
+			.map( ( rate ) => rate.name )
+			.filter( Boolean )
+	);
+	let rateNames: string[] = [];
+	if ( selectedNames.length > 0 ) {
+		rateNames = selectedNames;
+	} else if ( availableRateNames.length === 1 ) {
+		rateNames = availableRateNames;
+	}
+
+	const hasMultipleRates =
+		selectedNames.length > 1 || availableRateNames.length > 1;
 	const rowLabel =
-		! hasSelectedRates || hasMultipleRates ? label : rateNames[ 0 ];
+		rateNames.length === 0 || hasMultipleRates ? label : rateNames[ 0 ];

 	return (
 		<div className="wc-block-components-totals-shipping">
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 9cb57c8cfc7..16f499ffcdf 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
@@ -10,7 +10,6 @@ import { checkoutStore } from '@woocommerce/block-data';
 import {
 	filterShippingRatesByPrefersCollection,
 	hasAllFieldsForShippingRates,
-	selectedRatesAreCollectable,
 } from '@woocommerce/base-utils';

 const Block = ( {
@@ -28,19 +27,18 @@ const Block = ( {
 		return null;
 	}

-	const hasSelectedCollectionOnly = selectedRatesAreCollectable(
-		filterShippingRatesByPrefersCollection(
-			shippingRates,
-			prefersCollection ?? false
-		)
+	const filteredRates = filterShippingRatesByPrefersCollection(
+		shippingRates,
+		prefersCollection ?? false
 	);

 	const hasCompleteAddress = hasAllFieldsForShippingRates( shippingAddress );
 	return (
 		<TotalsWrapper className={ className }>
 			<TotalsShipping
+				shippingRates={ filteredRates }
 				label={
-					hasSelectedCollectionOnly
+					prefersCollection
 						? __( 'Pickup', 'woocommerce' )
 						: __( 'Delivery', 'woocommerce' )
 				}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx
index 8c53c3cfef9..f28f9727df9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx
@@ -190,6 +190,7 @@ const Block = ( {
 			// components-button-group is here for backwards compatibility, in case themes or plugins rely on it.
 			className="components-button-group wc-block-checkout__shipping-method-container"
 			role="radiogroup"
+			aria-label={ __( 'Shipping method', 'woocommerce' ) }
 		>
 			<ShippingSelector
 				checked={ checked }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx
index be8f9a59445..df703ce56f4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/frontend.tsx
@@ -5,8 +5,12 @@ import clsx from 'clsx';
 import { withFilteredAttributes } from '@woocommerce/shared-hocs';
 import { FormStep } from '@woocommerce/blocks-components';
 import { useDispatch, useSelect } from '@wordpress/data';
-import { checkoutStore as checkoutStoreDescriptor } from '@woocommerce/block-data';
+import {
+	checkoutStore as checkoutStoreDescriptor,
+	cartStore,
+} from '@woocommerce/block-data';
 import { useShippingData } from '@woocommerce/base-context/hooks';
+import { hasCollectableRate } from '@woocommerce/base-utils';
 import {
 	LOCAL_PICKUP_ENABLED,
 	SHIPPING_METHODS_EXIST,
@@ -51,7 +55,8 @@ const FrontendBlock = ( {
 	);

 	const { setPrefersCollection } = useDispatch( checkoutStoreDescriptor );
-	const { needsShipping, isCollectable } = useShippingData();
+	const { selectShippingRate } = useDispatch( cartStore );
+	const { needsShipping, isCollectable, shippingRates } = useShippingData();

 	// Note that display logic is also found in plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/register-components.ts
 	// where the block is not registered if the conditions are not met.
@@ -70,6 +75,19 @@ const FrontendBlock = ( {
 			setPrefersCollection( true );
 		} else {
 			setPrefersCollection( false );
+
+			// When switching to Ship, if no non-pickup shipping rates are
+			// available (hidden because no address entered), clear the pickup
+			// selection from the session so totals don't show the pickup cost.
+			const hasNonPickupRates = shippingRates.some(
+				( { shipping_rates: rates } ) =>
+					rates.some(
+						( rate ) => ! hasCollectableRate( rate.method_id )
+					)
+			);
+			if ( ! hasNonPickupRates ) {
+				selectShippingRate( '', null );
+			}
 		}
 	};

diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/actions.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/actions.ts
index c35c809f2eb..f5fa3903ade 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/actions.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/actions.ts
@@ -18,8 +18,11 @@ export * from './thunks';

 /**
  * An action creator that dispatches the plain action responsible for setting the cart data in the store.
+ *
+ * Accepts a `Partial<Cart>` because the reducer merges the response into existing
+ * cart state, supporting both full replacements and targeted updates.
  */
-export function setCartData( cart: Cart ) {
+export function setCartData( cart: Partial< Cart > ) {
 	return {
 		type: types.SET_CART_DATA,
 		response: cart,
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
index c6823bcda7b..6c899ee5b03 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
@@ -601,8 +601,28 @@ export const selectShippingRate =
 			return;
 		}

+		const previousRates = select.getShippingRates();
+
 		try {
 			dispatch.shippingRatesBeingSelected( true );
+
+			// Optimistically update the selected flag so the UI (labels, totals)
+			// reflects the new rate immediately without waiting for the API.
+			dispatch.setCartData( {
+				shippingRates: previousRates.map( ( pkg ) => {
+					if ( packageId !== null && pkg.package_id !== packageId ) {
+						return pkg;
+					}
+					return {
+						...pkg,
+						shipping_rates: pkg.shipping_rates.map( ( rate ) => ( {
+							...rate,
+							selected: rate.rate_id === rateId,
+						} ) ),
+					};
+				} ),
+			} );
+
 			if ( abortController ) {
 				abortController.abort();
 			}
@@ -636,6 +656,11 @@ 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.
+			dispatch.setCartData( {
+				shippingRates: previousRates,
+			} );
 			dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
 			dispatch.shippingRatesBeingSelected( false );
 			return Promise.reject( error );
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/general-settings.tsx b/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/general-settings.tsx
index a34b1d12625..dc1f70c26e1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/general-settings.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/general-settings.tsx
@@ -3,7 +3,7 @@
  */
 import { __, _x } from '@wordpress/i18n';
 import { createInterpolateElement, useState } from '@wordpress/element';
-import { ADMIN_URL, getSetting } from '@woocommerce/settings';
+import { ADMIN_URL } from '@woocommerce/settings';
 import { CHECKOUT_PAGE_ID } from '@woocommerce/block-settings';
 import {
 	CheckboxControl,
@@ -47,11 +47,6 @@ const GeneralSettings = () => {
 		useSettingsContext();
 	const [ showCosts, setShowCosts ] = useState( !! settings.cost );

-	const shippingCostRequiresAddress = getSetting< boolean >(
-		'shippingCostRequiresAddress',
-		false
-	);
-
 	return (
 		<SettingsSection Description={ GeneralSettingsDescription }>
 			<SettingsCard>
@@ -78,23 +73,10 @@ const GeneralSettings = () => {
 					name="local_pickup_enabled"
 					onChange={ setSettingField( 'enabled' ) }
 					label={ __( 'Enable local pickup', 'woocommerce' ) }
-					help={
-						<span>
-							{ __(
-								'When enabled, local pickup will appear as an option on the block based checkout.',
-								'woocommerce'
-							) }
-							{ shippingCostRequiresAddress ? (
-								<>
-									<br />
-									{ __(
-										'If local pickup is enabled, the "Hide shipping costs until an address is entered" setting will be ignored.',
-										'woocommerce'
-									) }
-								</>
-							) : null }
-						</span>
-					}
+					help={ __(
+						'When enabled, local pickup will appear as an option on the block based checkout.',
+						'woocommerce'
+					) }
 				/>
 				<TextControl
 					label={ __( 'Title', 'woocommerce' ) }
diff --git a/plugins/woocommerce/includes/wc-cart-functions.php b/plugins/woocommerce/includes/wc-cart-functions.php
index f5081cc5e92..a8632511c1f 100644
--- a/plugins/woocommerce/includes/wc-cart-functions.php
+++ b/plugins/woocommerce/includes/wc-cart-functions.php
@@ -512,6 +512,25 @@ function wc_get_default_shipping_method_for_package( $key, $package, $chosen_met
 				break;
 			}
 		}
+
+		// When shipping costs are hidden until an address is entered, don't auto-select pickup as the default.
+		// Without this, pickup gets silently selected because it's the only remaining rate after filtering.
+		if (
+			'' === $default
+			&& 'yes' === get_option( 'woocommerce_shipping_cost_requires_address' )
+			&& WC()->customer instanceof \WC_Customer
+			&& ! WC()->customer->has_full_shipping_address()
+		) {
+			/**
+			 * Filters the default shipping method for a package.
+			 *
+			 * @since 3.2.0
+			 * @param string $default Default shipping method.
+			 * @param array  $rates   Shipping rates.
+			 * @param string $chosen_method Chosen method id.
+			 */
+			return (string) apply_filters( 'woocommerce_shipping_chosen_method', $default, $package['rates'], $chosen_method );
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 2eb32ba7931..4de14ca7520 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -206,11 +206,10 @@
 	</rule>

 	<!-- LongConditionClosingComment wants `//end if` / `//end foreach` markers on
-	     long blocks. Archaic convention from legacy WP code that's not used in
-	     modern PHP 8.1+ code paths like the Code API and its infrastructure. -->
-	<rule ref="Squiz.Commenting.LongConditionClosingComment">
-		<exclude-pattern>src/Api/</exclude-pattern>
-		<exclude-pattern>src/Internal/Api/</exclude-pattern>
+	     long blocks. Archaic convention from legacy WP code that adds noise without
+	     improving readability in modern editors. Disabled project-wide. -->
+	<rule ref="Squiz.Commenting.LongConditionClosingComment.Missing">
+		<severity>0</severity>
 	</rule>

 	<!-- Autogenerated API code: suppress rules for generated files -->
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-default-shipping-method-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-default-shipping-method-test.php
new file mode 100644
index 00000000000..df411075f05
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-default-shipping-method-test.php
@@ -0,0 +1,154 @@
+<?php
+/**
+ * Tests for wc_get_default_shipping_method_for_package().
+ *
+ * @package WooCommerce\Tests\Includes
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Tests for wc_get_default_shipping_method_for_package().
+ */
+class WC_Cart_Default_Shipping_Method_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Shipping zone used across tests.
+	 *
+	 * @var WC_Shipping_Zone
+	 */
+	private $zone;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// Create a shipping zone with a flat rate so CartCheckoutUtils::shipping_methods_exist() returns true.
+		$this->zone = new WC_Shipping_Zone();
+		$this->zone->set_zone_name( 'Test Zone' );
+		$this->zone->save();
+		$this->zone->add_shipping_method( 'flat_rate' );
+
+		// Flush the shipping method count transient so the new zone is picked up.
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+		delete_transient( 'wc_shipping_method_count' );
+
+		// Set block checkout context (not shortcode).
+		WC()->cart->cart_context = 'store-api';
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->zone->delete( true );
+		update_option( 'woocommerce_shipping_cost_requires_address', 'no' );
+		WC()->cart->cart_context = 'shortcode';
+		parent::tearDown();
+	}
+
+	/**
+	 * Build a test shipping package with the given rate keys.
+	 *
+	 * @param array $rate_keys e.g. ['flat_rate:1', 'local_pickup:1'].
+	 * @return array
+	 */
+	private function build_package( array $rate_keys ): array {
+		$rates = array();
+		foreach ( $rate_keys as $rate_key ) {
+			$method_id          = current( explode( ':', $rate_key ) );
+			$rates[ $rate_key ] = new WC_Shipping_Rate( $rate_key, ucfirst( $method_id ), '10', array(), $method_id );
+		}
+		return array( 'rates' => $rates );
+	}
+
+	/**
+	 * Clear the customer shipping address.
+	 */
+	private function clear_customer_address(): void {
+		WC()->customer->set_shipping_country( '' );
+		WC()->customer->set_shipping_state( '' );
+		WC()->customer->set_shipping_postcode( '' );
+		WC()->customer->set_shipping_city( '' );
+	}
+
+	/**
+	 * Test default method with only pickup rates and no address.
+	 *
+	 * @testdox Returns empty string when only pickup rates remain and hide-shipping-costs is enabled with no address.
+	 */
+	public function test_returns_empty_when_only_pickup_and_no_address(): void {
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+		$this->clear_customer_address();
+
+		$package = $this->build_package( array( 'local_pickup:1' ) );
+		$result  = wc_get_default_shipping_method_for_package( 0, $package, '' );
+
+		$this->assertSame( '', $result, 'Should not auto-select pickup when shipping costs are hidden and no address entered' );
+	}
+
+	/**
+	 * Test default method with both shipping and pickup rates.
+	 *
+	 * @testdox Returns a shipping rate when both shipping and pickup rates exist.
+	 */
+	public function test_returns_shipping_rate_when_shipping_and_pickup_available(): void {
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+		$this->clear_customer_address();
+
+		$package = $this->build_package( array( 'flat_rate:1', 'local_pickup:1' ) );
+		$result  = wc_get_default_shipping_method_for_package( 0, $package, '' );
+
+		$this->assertSame( 'flat_rate:1', $result, 'Should select the first non-pickup shipping rate' );
+	}
+
+	/**
+	 * Test default method selects shipping rate when setting is enabled but address exists.
+	 *
+	 * @testdox Returns shipping rate when hide-shipping-costs is enabled but customer has a full address.
+	 */
+	public function test_returns_shipping_rate_when_setting_enabled_and_address_complete(): void {
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+		WC()->customer->set_shipping_country( 'US' );
+		WC()->customer->set_shipping_state( 'CA' );
+		WC()->customer->set_shipping_postcode( '90210' );
+		WC()->customer->set_shipping_city( 'Beverly Hills' );
+
+		$package = $this->build_package( array( 'flat_rate:1', 'local_pickup:1' ) );
+		$result  = wc_get_default_shipping_method_for_package( 0, $package, '' );
+
+		$this->assertSame( 'flat_rate:1', $result, 'Should select shipping rate when customer has a full address' );
+	}
+
+	/**
+	 * Test default method preserves previously chosen pickup.
+	 *
+	 * @testdox Preserves local pickup when it was previously chosen by the customer.
+	 */
+	public function test_preserves_chosen_local_pickup(): void {
+		update_option( 'woocommerce_shipping_cost_requires_address', 'no' );
+
+		$package = $this->build_package( array( 'flat_rate:1', 'local_pickup:1' ) );
+		$result  = wc_get_default_shipping_method_for_package( 0, $package, 'local_pickup:1' );
+
+		$this->assertSame( 'local_pickup:1', $result, 'Should preserve previously chosen local pickup' );
+	}
+
+	/**
+	 * Test shortcode context is unaffected.
+	 *
+	 * @testdox Shortcode context always selects first rate regardless of settings.
+	 */
+	public function test_shortcode_context_unaffected(): void {
+		WC()->cart->cart_context = 'shortcode';
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+		$this->clear_customer_address();
+
+		$package = $this->build_package( array( 'local_pickup:1' ) );
+		$result  = wc_get_default_shipping_method_for_package( 0, $package, '' );
+
+		$this->assertSame( 'local_pickup:1', $result, 'Shortcode context should always select the first rate' );
+	}
+}