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' );
+ }
+}