Commit 9966df35335 for woocommerce

commit 9966df353350a44b141f6ad053f9a69d2558cd5f
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Nov 4 13:52:08 2026 +0100

    Use a chosen shipping method only when its rate was offered (#70629)

    Checkout, the cart AJAX handlers and the Store API now keep a chosen shipping method only when WooCommerce calculated its rate for the cart package. The shipping rate cache key includes the customer tax address.

    Co-authored-by: Ayush Pahwa <8526215+ayushpahwa@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-chosen-shipping-method-offered b/plugins/woocommerce/changelog/fix-chosen-shipping-method-offered
new file mode 100644
index 00000000000..9a689a9d83e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-chosen-shipping-method-offered
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use a chosen shipping method only when its rate was calculated for the cart.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 3d25d949022..4d796913170 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -23,6 +23,7 @@ use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
 use Automattic\WooCommerce\Utilities\StringUtil;

 defined( 'ABSPATH' ) || exit;
@@ -345,7 +346,7 @@ class WC_AJAX {

 		if ( is_array( $posted_shipping_methods ) ) {
 			foreach ( $posted_shipping_methods as $i => $value ) {
-				if ( ! is_string( $value ) ) {
+				if ( ! is_string( $value ) || ! ShippingUtil::is_shipping_rate_listed( $i, $value ) ) {
 					continue;
 				}
 				$chosen_shipping_methods[ $i ] = $value;
@@ -428,7 +429,7 @@ class WC_AJAX {

 		if ( is_array( $posted_shipping_methods ) ) {
 			foreach ( $posted_shipping_methods as $i => $value ) {
-				if ( ! is_string( $value ) ) {
+				if ( ! is_string( $value ) || ! ShippingUtil::is_shipping_rate_listed( $i, $value ) ) {
 					continue;
 				}
 				$chosen_shipping_methods[ $i ] = $value;
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 261a38dcc6b..29bb2f15fd2 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Internal\ProductVariations\SelectedVariationName;
 use Automattic\WooCommerce\Internal\Tax\TaxRateDataStore;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
 use Automattic\WooCommerce\StoreApi\Utilities\CartController;

 defined( 'ABSPATH' ) || exit;
@@ -1414,12 +1415,15 @@ class WC_Checkout {
 		array_walk( $address_fields, array( $this, 'set_customer_address_fields' ), $data );
 		WC()->customer->save();

+		// Calculate the rates for the posted address, so the posted choices are checked against them.
+		WC()->cart->calculate_shipping();
+
 		// Update customer shipping and payment method to posted method.
 		$chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods' );

 		if ( is_array( $data['shipping_method'] ) ) {
 			foreach ( $data['shipping_method'] as $i => $value ) {
-				if ( ! is_string( $value ) ) {
+				if ( ! is_string( $value ) || ! ShippingUtil::is_shipping_rate_listed( $i, $value ) ) {
 					continue;
 				}
 				$chosen_shipping_methods[ $i ] = $value;
@@ -1645,6 +1649,11 @@ class WC_Checkout {
 				throw new Exception( $customer_id->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
 			}

+			// Save the posted choices, because the login loads the session again from the database.
+			if ( is_callable( array( WC()->session, 'save_data' ) ) ) {
+				WC()->session->save_data();
+			}
+
 			wc_set_customer_auth_cookie( $customer_id );

 			// As we are now logged in, checkout will need to refresh to show logged in data.
diff --git a/plugins/woocommerce/includes/class-wc-shipping.php b/plugins/woocommerce/includes/class-wc-shipping.php
index 38659a62754..b1764788d87 100644
--- a/plugins/woocommerce/includes/class-wc-shipping.php
+++ b/plugins/woocommerce/includes/class-wc-shipping.php
@@ -417,6 +417,11 @@ class WC_Shipping {
 	 * @return string
 	 */
 	private function get_package_hash( array $package_to_hash ): string {
+		// The rate taxes depend on the tax address, so a new tax address must not reuse saved rates.
+		if ( wc_tax_enabled() && WC()->customer instanceof WC_Customer ) {
+			$package_to_hash['tax_location'] = WC()->customer->get_taxable_address();
+		}
+
 		/**
 		 * Filters package fields that should not affect the shipping-rate cache hash.
 		 *
diff --git a/plugins/woocommerce/includes/wc-cart-functions.php b/plugins/woocommerce/includes/wc-cart-functions.php
index b13e195e84c..2c5f71bc6e9 100644
--- a/plugins/woocommerce/includes/wc-cart-functions.php
+++ b/plugins/woocommerce/includes/wc-cart-functions.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
 use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
+use Automattic\WooCommerce\Utilities\ShippingUtil;

 defined( 'ABSPATH' ) || exit;

@@ -435,6 +436,8 @@ function wc_cart_round_discount( $value, $precision ) {
 /**
  * Gets chosen shipping method IDs from chosen_shipping_methods session, without instance IDs.
  *
+ * Only choices whose rate was listed for the cart are returned, see ShippingUtil::filter_chosen_shipping_methods().
+ *
  * @since  2.6.2
  * @return string[]
  */
@@ -443,7 +446,7 @@ function wc_get_chosen_shipping_method_ids() {
 		return array();
 	}

-	$chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() );
+	$chosen_methods = ShippingUtil::filter_chosen_shipping_methods( WC()->session->get( 'chosen_shipping_methods', array() ) );
 	$method_ids     = array();

 	foreach ( $chosen_methods as $chosen_method ) {
diff --git a/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php b/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
index cf0edf5ca46..5ef6d915e83 100644
--- a/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
+++ b/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\TaxDisplayMode;
 use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
 use WC_Customer;
 use WC_Shipping_Rate;

@@ -446,7 +447,12 @@ class ShippingController {
 			return $address;
 		}
 		// We only need to select from the first package, since pickup_location only supports a single package.
-		$chosen_method          = current( WC()->session->get( 'chosen_shipping_methods', array() ) ) ?? '';
+		// The first entry is used only when its rate was listed for the cart.
+		$chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() );
+		$first_key      = is_array( $chosen_methods ) && ! empty( $chosen_methods ) ? array_key_first( $chosen_methods ) : null;
+		$listed_methods = null !== $first_key ? ShippingUtil::filter_chosen_shipping_methods( $chosen_methods ) : array();
+		$chosen_method  = null !== $first_key && isset( $listed_methods[ $first_key ] ) ? (string) $listed_methods[ $first_key ] : '';
+
 		$chosen_method_id       = explode( ':', $chosen_method )[0];
 		$chosen_method_instance = explode( ':', $chosen_method )[1] ?? 0;

diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index 51523691ded..742a382fdf2 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\StoreApi\Utilities\ArrayUtils;
 use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
 use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler;
 use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
 use WP_Error;

 /**
@@ -936,11 +937,13 @@ class CartController {
 	/**
 	 * Selects a shipping rate.
 	 *
+	 * The rate must be listed for the package. Other choices are ignored.
+	 *
 	 * @param int|string $package_id ID of the package to choose a rate for.
 	 * @param string     $rate_id ID of the rate being chosen.
 	 */
 	public function select_shipping_rate( $package_id, $rate_id ) {
-		if ( ! is_string( $rate_id ) ) {
+		if ( ! is_string( $rate_id ) || ! ShippingUtil::is_shipping_rate_listed( $package_id, $rate_id ) ) {
 			return;
 		}
 		$cart                        = $this->get_cart_instance();
diff --git a/plugins/woocommerce/src/Utilities/ShippingUtil.php b/plugins/woocommerce/src/Utilities/ShippingUtil.php
index 2bd89656c7d..9a8685ad09d 100644
--- a/plugins/woocommerce/src/Utilities/ShippingUtil.php
+++ b/plugins/woocommerce/src/Utilities/ShippingUtil.php
@@ -11,6 +11,13 @@ namespace Automattic\WooCommerce\Utilities;
  */
 class ShippingUtil {

+	/**
+	 * The package keys of the cart from the last build, with the cart state they belong to.
+	 *
+	 * @var array{state: string, keys: array<int|string, true>|null}|null
+	 */
+	private static $cart_package_keys = null;
+
 	/**
 	 * Get the selected shipping rates from the packages.
 	 *
@@ -31,4 +38,125 @@ class ShippingUtil {
 			)
 		);
 	}
+
+	/**
+	 * Check that a rate was listed for a package key the last time shipping was calculated.
+	 *
+	 * A rate id in the `chosen_shipping_methods` session value is used only when the `previous_shipping_methods`
+	 * session value lists it for the same key.
+	 *
+	 * @since 11.2.2
+	 *
+	 * @param int|string $package_key Package key, as used in `chosen_shipping_methods`.
+	 * @param mixed      $rate_id     Rate id to check. Only strings can match.
+	 * @return bool
+	 */
+	public static function is_shipping_rate_listed( $package_key, $rate_id ): bool {
+		if ( ! is_string( $rate_id ) || ( ! is_int( $package_key ) && ! is_string( $package_key ) ) || ! is_callable( array( WC()->session, 'get' ) ) ) {
+			return false;
+		}
+
+		$listed_rates = WC()->session->get( 'previous_shipping_methods' );
+		$rate_ids     = is_array( $listed_rates ) ? ( $listed_rates[ $package_key ] ?? null ) : null;
+
+		// Compare as strings: array_keys() turns a numeric rate id into an int.
+		return is_array( $rate_ids ) && in_array( $rate_id, array_map( 'strval', array_filter( $rate_ids, 'is_scalar' ) ), true );
+	}
+
+	/**
+	 * Keep only the chosen methods whose rates were listed for the current cart packages.
+	 *
+	 * Each rate must be listed for its key. Key must be a shipping package of
+	 * the cart at the time of request. The session can keep keys from earlier requests and keys
+	 * that other code adds for carts other than this one.
+	 *
+	 * @since 11.2.2
+	 *
+	 * @param mixed $chosen_methods Chosen methods keyed by package key, usually the `chosen_shipping_methods` session value.
+	 * @return array The listed entries with their keys kept. Empty when the input is not an array.
+	 */
+	public static function filter_chosen_shipping_methods( $chosen_methods ): array {
+		if ( ! is_array( $chosen_methods ) || ! is_callable( array( WC()->session, 'get' ) ) ) {
+			return array();
+		}
+
+		$listed_rates   = WC()->session->get( 'previous_shipping_methods' );
+		$listed_rates   = is_array( $listed_rates ) ? $listed_rates : array();
+		$chosen_methods = array_filter(
+			$chosen_methods,
+			function ( $rate_id, $package_key ) use ( $listed_rates ) {
+				$rate_ids = $listed_rates[ $package_key ] ?? null;
+
+				// Compare as strings: array_keys() turns a numeric rate id into an int.
+				return is_string( $rate_id ) && is_array( $rate_ids ) && in_array( $rate_id, array_map( 'strval', array_filter( $rate_ids, 'is_scalar' ) ), true );
+			},
+			ARRAY_FILTER_USE_BOTH
+		);
+
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Documented in WC_Abstract_Order::get_tax_location().
+		$pickup_method_ids = apply_filters( 'woocommerce_local_pickup_methods', array( 'legacy_local_pickup', 'local_pickup' ) );
+		$pickup_method_ids = is_array( $pickup_method_ids ) ? $pickup_method_ids : array();
+		$package_keys      = null;
+
+		foreach ( $chosen_methods as $package_key => $rate_id ) {
+			if ( ! in_array( explode( ':', $rate_id )[0], $pickup_method_ids, true ) ) {
+				continue;
+			}
+
+			// This runs on every tax rate lookup, so build the keys again only when the cart state changes.
+			// The totals counters change for each Subscriptions recurring cart, which uses other keys.
+			if ( null === $package_keys ) {
+				$cart = WC()->cart;
+				if ( ! $cart instanceof \WC_Cart ) {
+					$package_keys = array();
+				} else {
+					$customer = WC()->customer;
+					$state    = md5(
+						(string) wp_json_encode(
+							array(
+								spl_object_id( $cart ),
+								wp_list_pluck( $cart->get_cart_contents(), 'quantity' ),
+								$customer instanceof \WC_Customer ? array(
+									$customer->get_shipping_country(),
+									$customer->get_shipping_state(),
+									$customer->get_shipping_postcode(),
+									$customer->get_shipping_city(),
+									$customer->get_shipping_address_1(),
+									$customer->get_shipping_address_2(),
+								) : array(),
+								did_action( 'woocommerce_before_calculate_totals' ),
+								did_action( 'woocommerce_after_calculate_totals' ),
+							)
+						)
+					);
+
+					if ( null === self::$cart_package_keys || self::$cart_package_keys['state'] !== $state ) {
+						// Save the state first: a nested call from the package filter sees null keys, keeps the pickup and does not build the packages again.
+						self::$cart_package_keys = array(
+							'state' => $state,
+							'keys'  => null,
+						);
+						try {
+							self::$cart_package_keys['keys'] = array_fill_keys( array_keys( $cart->get_shipping_packages() ), true );
+						} finally {
+							// A build that did not complete leaves the keys for the current state null.
+							if ( null === ( self::$cart_package_keys['keys'] ?? null ) ) {
+								self::$cart_package_keys = null;
+							}
+						}
+					}
+					$package_keys = self::$cart_package_keys['keys'] ?? null;
+					if ( null === $package_keys ) {
+						break;
+					}
+				}
+			}
+
+			if ( ! isset( $package_keys[ $package_key ] ) ) {
+				unset( $chosen_methods[ $package_key ] );
+			}
+		}
+
+		return $chosen_methods;
+	}
 }
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/customer/customer.php b/plugins/woocommerce/tests/legacy/unit-tests/customer/customer.php
index de73d7ccdaa..3b6cf0a7166 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/customer/customer.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/customer/customer.php
@@ -23,6 +23,7 @@ class WC_Tests_Customer extends WC_Unit_Test_Case {
 		// Create dummy product, and add the product to the cart.
 		$product = WC_Helper_Product::create_simple_product();
 		WC()->cart->add_to_cart( $product->get_id(), 1 );
+		WC()->session->set( 'previous_shipping_methods', array( array( 'local_pickup', 'free_shipping' ) ) );

 		// Customer is going with the Local Pickup option, and the store calculates tax based on the store location.
 		WC_Helper_Customer::set_chosen_shipping_methods( array( 'local_pickup' ) );
@@ -69,6 +70,7 @@ class WC_Tests_Customer extends WC_Unit_Test_Case {
 		$product = WC_Helper_Product::create_simple_product();

 		WC()->cart->add_to_cart( $product->get_id(), 1 );
+		WC()->session->set( 'previous_shipping_methods', array( array( 'local_pickup', 'free_shipping' ) ) );

 		// Customer is going with the Local Pickup option, and the store calculates tax based on the store location.
 		WC_Helper_Customer::set_chosen_shipping_methods( array( 'local_pickup' ) );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
index 6eb559d0448..e6f8ee961cf 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
@@ -182,6 +182,7 @@ class WC_Admin_Reports_Customers_Controller_Test extends WC_Unit_Test_Case {
 	public static function wpTearDownAfterClass() {
 		remove_action( 'action_scheduler_stored_action', array( self::class, 'track_import_action' ) );
 		WC_Helper_Reports::reset_stats_dbs();
+		WC_Helper_Shipping::delete_simple_flat_rate();
 		$action_store = ActionScheduler_Store::instance();
 		foreach ( self::$import_action_ids as $action_id ) {
 			$action_store->delete_action( $action_id );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index 8c4b266a18a..9e5f51a0c9b 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -910,6 +910,264 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( 8.0, (float) WC()->cart->get_shipping_total(), 'The order must be charged the shipping rate.' );
 	}

+	/**
+	 * @testdox 'update_session' keeps only posted shipping rates that are listed for their package key.
+	 */
+	public function test_update_session_ignores_posted_shipping_rates_that_are_not_listed(): void {
+		WC_Helper_Shipping::create_simple_flat_rate( 8 );
+
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+		$original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Restored in the finally block.
+		$_POST         = array(
+			'billing_first_name' => 'Test',
+			'billing_last_name'  => 'Shopper',
+			'billing_phone'      => '5555555555',
+			'billing_email'      => 'shopper@example.com',
+			'billing_country'    => 'US',
+			'billing_state'      => 'NY',
+			'billing_postcode'   => '10001',
+			'billing_city'       => 'New York',
+			'billing_address_1'  => '1 Main St',
+			'shipping_method'    => array(
+				0       => 'local_pickup',
+				'extra' => 'local_pickup',
+			),
+			'payment_method'     => 'cod',
+		);
+
+		try {
+			$this->sut->update_session( $this->sut->get_posted_data() );
+		} finally {
+			$_POST = $original_post;
+			WC_Helper_Shipping::delete_simple_flat_rate();
+		}
+
+		$chosen_methods = WC()->session->get( 'chosen_shipping_methods' );
+
+		$this->assertArrayNotHasKey( 'extra', $chosen_methods, 'A posted choice for a key with no listed rates must not be saved.' );
+		$this->assertSame( 'legacy_flat_rate', $chosen_methods[0], 'A posted rate that is not listed must not replace the listed rate.' );
+	}
+
+	/**
+	 * @testdox 'update_session' taxes the posted shipping rate at the posted address when a pickup rate was chosen before and the item taxes do not change with the address.
+	 */
+	public function test_update_session_taxes_posted_shipping_rate_at_posted_address(): void {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_tax_based_on', 'shipping' );
+		update_option( 'woocommerce_default_country', 'US:CA' );
+		update_option( 'woocommerce_shipping_tax_class', '' );
+
+		foreach ( array( array( 'US', 'CA', '10.0000', 'CA tax' ), array( 'GB', '', '20.0000', 'GB tax' ) ) as list( $country, $state, $rate, $name ) ) {
+			WC_Tax::_insert_tax_rate(
+				array(
+					'tax_rate_country'  => $country,
+					'tax_rate_state'    => $state,
+					'tax_rate'          => $rate,
+					'tax_rate_name'     => $name,
+					'tax_rate_priority' => '1',
+					'tax_rate_compound' => '0',
+					'tax_rate_shipping' => '1',
+					'tax_rate_order'    => '1',
+					'tax_rate_class'    => '',
+				)
+			);
+		}
+
+		$base_zone = new WC_Shipping_Zone();
+		$base_zone->set_zone_name( 'Store base' );
+		$base_zone->save();
+		$base_zone->add_location( 'US:CA', 'state' );
+		$base_zone->save();
+		$pickup_id = $base_zone->add_shipping_method( 'local_pickup' );
+
+		$delivery_zone = new WC_Shipping_Zone();
+		$delivery_zone->set_zone_name( 'Delivery' );
+		$delivery_zone->save();
+		$delivery_zone->add_location( 'GB', 'country' );
+		$delivery_zone->save();
+		$flat_rate_id = $delivery_zone->add_shipping_method( 'flat_rate' );
+		update_option(
+			"woocommerce_flat_rate_{$flat_rate_id}_settings",
+			array(
+				'title'      => 'Flat rate',
+				'tax_status' => 'taxable',
+				'cost'       => '5',
+			)
+		);
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+		$product = WC_Helper_Product::create_simple_product( true, array( 'tax_status' => 'none' ) );
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+		WC()->customer->set_is_vat_exempt( false );
+		WC()->customer->set_billing_location( 'US', 'CA', '90001', 'Los Angeles' );
+		WC()->customer->set_shipping_location( 'US', 'CA', '90001', 'Los Angeles' );
+		WC()->cart->calculate_totals();
+		WC()->session->set( 'chosen_shipping_methods', array( "local_pickup:{$pickup_id}" ) );
+		WC()->cart->calculate_totals();
+
+		$this->assertSame( array( "local_pickup:{$pickup_id}" ), WC()->session->get( 'chosen_shipping_methods' ), 'The pickup rate must be the chosen rate at the store address.' );
+
+		$original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Restored in the finally block.
+		$_POST         = array(
+			'billing_first_name' => 'Test',
+			'billing_last_name'  => 'Shopper',
+			'billing_phone'      => '5555555555',
+			'billing_email'      => 'shopper@example.com',
+			'billing_country'    => 'GB',
+			'billing_postcode'   => 'SW1A 1AA',
+			'billing_city'       => 'London',
+			'billing_address_1'  => '1 Main St',
+			'shipping_method'    => array( "flat_rate:{$flat_rate_id}" ),
+			'payment_method'     => 'cod',
+		);
+
+		try {
+			$this->sut->update_session( $this->sut->get_posted_data() );
+		} finally {
+			$_POST = $original_post;
+		}
+
+		$this->assertSame( array( "flat_rate:{$flat_rate_id}" ), WC()->session->get( 'chosen_shipping_methods' ), 'The posted rate must be the chosen rate.' );
+		$this->assertSame( 5.0, (float) WC()->cart->get_shipping_total(), 'The cart must be charged the posted rate.' );
+		$this->assertSame( 1.0, (float) WC()->cart->get_shipping_tax(), 'The shipping tax must be the tax of the posted address.' );
+	}
+
+	/**
+	 * @testdox 'process_customer' keeps the posted shipping rate, taxed at the posted address, when the checkout creates an account.
+	 *
+	 * @testWith ["GB", "", "SW1A 1AA", "London", 5.0, 1.0]
+	 *           ["US", "CA", "90001", "Los Angeles", 7.0, 0.7]
+	 *
+	 * @param string $country               Posted billing country.
+	 * @param string $state                 Posted billing state.
+	 * @param string $postcode              Posted billing postcode.
+	 * @param string $city                  Posted billing city.
+	 * @param float  $expected_shipping     Cost of the posted flat rate.
+	 * @param float  $expected_shipping_tax Tax of the posted flat rate at the posted address.
+	 */
+	public function test_process_customer_keeps_posted_shipping_rate_when_creating_an_account( string $country, string $state, string $postcode, string $city, float $expected_shipping, float $expected_shipping_tax ): void {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_tax_based_on', 'shipping' );
+		update_option( 'woocommerce_default_country', 'US:CA' );
+		update_option( 'woocommerce_shipping_tax_class', '' );
+		update_option( 'woocommerce_enable_signup_and_login_from_checkout', 'yes' );
+
+		foreach ( array( array( 'US', 'CA', '10.0000', 'CA tax' ), array( 'GB', '', '20.0000', 'GB tax' ) ) as list( $rate_country, $rate_state, $rate, $name ) ) {
+			WC_Tax::_insert_tax_rate(
+				array(
+					'tax_rate_country'  => $rate_country,
+					'tax_rate_state'    => $rate_state,
+					'tax_rate'          => $rate,
+					'tax_rate_name'     => $name,
+					'tax_rate_priority' => '1',
+					'tax_rate_compound' => '0',
+					'tax_rate_shipping' => '1',
+					'tax_rate_order'    => '1',
+					'tax_rate_class'    => '',
+				)
+			);
+		}
+
+		$base_zone = new WC_Shipping_Zone();
+		$base_zone->set_zone_name( 'Store base' );
+		$base_zone->save();
+		$base_zone->add_location( 'US:CA', 'state' );
+		$base_zone->save();
+		$pickup_id = $base_zone->add_shipping_method( 'local_pickup' );
+
+		$delivery_zone = new WC_Shipping_Zone();
+		$delivery_zone->set_zone_name( 'Delivery' );
+		$delivery_zone->save();
+		$delivery_zone->add_location( 'GB', 'country' );
+		$delivery_zone->save();
+
+		$flat_rate_ids   = array(
+			'US' => $base_zone->add_shipping_method( 'flat_rate' ),
+			'GB' => $delivery_zone->add_shipping_method( 'flat_rate' ),
+		);
+		$flat_rate_costs = array(
+			'US' => '7',
+			'GB' => '5',
+		);
+		foreach ( $flat_rate_costs as $zone_country => $cost ) {
+			update_option(
+				"woocommerce_flat_rate_{$flat_rate_ids[ $zone_country ]}_settings",
+				array(
+					'title'      => 'Flat rate',
+					'tax_status' => 'taxable',
+					'cost'       => $cost,
+				)
+			);
+		}
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+		// Keep the session cookie in $_COOKIE, where the login reads it, instead of sending a header.
+		$cookie_name  = '';
+		$store_cookie = function ( $enabled, $name, $value ) use ( &$cookie_name ) {
+			$cookie_name             = $name;
+			$_COOKIE[ $cookie_name ] = $value;
+			return false;
+		};
+
+		$posted_rate      = "flat_rate:{$flat_rate_ids[ $country ]}";
+		$original_session = WC()->session;
+		$original_post    = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Restored in the finally block.
+
+		try {
+			wp_set_current_user( 0 );
+			add_filter( 'woocommerce_set_cookie_enabled', $store_cookie, 10, 3 );
+			WC()->session = new WC_Session_Handler();
+			WC()->session->init_session_cookie();
+			WC()->session->set_customer_session_cookie( true );
+
+			$product = WC_Helper_Product::create_simple_product( true, array( 'tax_status' => 'none' ) );
+			WC()->cart->add_to_cart( $product->get_id(), 1 );
+			WC()->customer->set_is_vat_exempt( false );
+			WC()->customer->set_billing_location( 'US', 'CA', '90001', 'Los Angeles' );
+			WC()->customer->set_shipping_location( 'US', 'CA', '90001', 'Los Angeles' );
+			WC()->cart->calculate_totals();
+			WC()->session->set( 'chosen_shipping_methods', array( "local_pickup:{$pickup_id}" ) );
+			WC()->cart->calculate_totals();
+			WC()->session->save_data();
+
+			$this->assertSame( array( "local_pickup:{$pickup_id}" ), WC()->session->get( 'chosen_shipping_methods' ), 'The pickup rate must be the chosen rate at the store address.' );
+
+			$_POST = array(
+				'billing_first_name' => 'Test',
+				'billing_last_name'  => 'Shopper',
+				'billing_phone'      => '5555555555',
+				'billing_email'      => 'new-shopper@example.com',
+				'billing_country'    => $country,
+				'billing_state'      => $state,
+				'billing_postcode'   => $postcode,
+				'billing_city'       => $city,
+				'billing_address_1'  => '1 Main St',
+				'shipping_method'    => array( $posted_rate ),
+				'payment_method'     => 'cod',
+				'createaccount'      => '1',
+			);
+
+			$data = $this->sut->get_posted_data();
+			$this->sut->update_session( $data );
+			$this->sut->process_customer( $data );
+
+			$this->assertTrue( is_user_logged_in(), 'The checkout must create the account and log the customer in.' );
+			$this->assertSame( array( $posted_rate ), WC()->session->get( 'chosen_shipping_methods' ), 'The posted rate must still be the chosen rate after the login.' );
+			$this->assertSame( $expected_shipping, (float) WC()->cart->get_shipping_total(), 'The cart must be charged the posted rate.' );
+			$this->assertSame( $expected_shipping_tax, (float) WC()->cart->get_shipping_tax(), 'The shipping tax must be the tax of the posted address.' );
+		} finally {
+			remove_filter( 'woocommerce_set_cookie_enabled', $store_cookie, 10 );
+			if ( '' !== $cookie_name ) {
+				unset( $_COOKIE[ $cookie_name ] );
+			}
+			wp_set_current_user( 0 );
+			WC()->session = $original_session;
+			$_POST        = $original_post;
+		}
+	}
+
 	/**
 	 * @testdox 'validate_checkout' still accepts an order when a plugin deliberately turns off shipping calculation.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php b/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
index 1c26cbd8eea..8b3545e83fe 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
@@ -155,6 +155,31 @@ class WC_Customer_Test extends \WC_Unit_Test_Case {
 		$this->assertFalse( $nested, 'The outermost nested check should still treat a missing postcode as incomplete.' );
 	}

+	/**
+	 * @testdox get_taxable_address uses the store address only for a pickup rate listed for a cart package.
+	 *
+	 * @testWith [ { "0": "local_pickup:2" }, { "0": [ "local_pickup:2", "flat_rate:1" ] }, "US" ]
+	 *           [ { "0": "local_pickup:2" }, { "0": [ "flat_rate:1" ] }, "GB" ]
+	 *           [ { "0": "flat_rate:1", "other": "local_pickup:2" }, { "0": [ "flat_rate:1" ], "other": [ "local_pickup:2" ] }, "GB" ]
+	 *
+	 * @param array  $chosen_methods   The `chosen_shipping_methods` session value.
+	 * @param array  $listed_rates     The `previous_shipping_methods` session value.
+	 * @param string $expected_country The expected taxable country.
+	 */
+	public function test_get_taxable_address_checks_chosen_pickup( array $chosen_methods, array $listed_rates, string $expected_country ): void {
+		update_option( 'woocommerce_default_country', 'US:CA' );
+		update_option( 'woocommerce_tax_based_on', 'shipping' );
+		WC()->session->set( 'chosen_shipping_methods', $chosen_methods );
+		WC()->session->set( 'previous_shipping_methods', $listed_rates );
+
+		$customer = new WC_Customer();
+		$customer->set_shipping_country( 'GB' );
+		$customer->set_shipping_postcode( 'SW1A 2AA' );
+		$customer->set_shipping_city( 'London' );
+
+		$this->assertSame( $expected_country, $customer->get_taxable_address()[0] );
+	}
+
 	/**
 	 * Get a customer whose US shipping address has everything but a postcode.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
index 0ef2b133c72..d96f3a39c6b 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
@@ -12,6 +12,11 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 	 */
 	private $sut;

+	/**
+	 * @var array The customer shipping address before the test.
+	 */
+	private $previous_shipping_address = array();
+
 	/**
 	 * Set up test
 	 *
@@ -22,12 +27,19 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 		$this->sut = new WC_Shipping();

 		update_option( 'woocommerce_shipping_debug_mode', 'yes' );
+
+		// Tests set the shipping address on WC()->customer, which outlives a test.
+		$this->previous_shipping_address = WC()->customer->get_shipping( 'edit' );
 	}

 	/**
 	 * Restore options.
 	 */
 	public function tearDown(): void {
+		foreach ( $this->previous_shipping_address as $key => $value ) {
+			WC()->customer->{"set_shipping_{$key}"}( $value );
+		}
+
 		parent::tearDown();

 		update_option( 'woocommerce_shipping_debug_mode', 'no' );
@@ -317,6 +329,68 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 		remove_filter( 'woocommerce_package_rates', $filter, 10 );
 	}

+	/**
+	 * @testdox a new customer tax address invalidates cached shipping rates for the same package only while taxes are enabled
+	 *
+	 * @testWith ["yes", 2]
+	 *           ["no", 1]
+	 *
+	 * @param string $calc_taxes             Value of the woocommerce_calc_taxes option.
+	 * @param int    $expected_calculations  Number of rate calculations for the two tax addresses.
+	 */
+	public function test_calculate_shipping_for_package_invalidates_cache_for_new_tax_address( string $calc_taxes, int $expected_calculations ) {
+		update_option( 'woocommerce_shipping_debug_mode', 'no' );
+		update_option( 'woocommerce_calc_taxes', $calc_taxes );
+		WC()->session->__unset( 'shipping_for_package_0' );
+
+		$filter_calls = 0;
+		$filter       = $this->get_package_rates_counter( $filter_calls );
+		$package      = $this->get_package_hash_test_package();
+
+		add_filter( 'woocommerce_package_rates', $filter, 10 );
+
+		$this->set_customer_tax_location( 'US', 'CA' );
+		$this->sut->calculate_shipping_for_package( $package );
+
+		$this->set_customer_tax_location( 'GB', '' );
+		$this->sut->calculate_shipping_for_package( $package );
+
+		$this->assertSame( $expected_calculations, $filter_calls );
+
+		remove_filter( 'woocommerce_package_rates', $filter, 10 );
+	}
+
+	/**
+	 * @testdox extensions can ignore the customer tax address for the shipping-rate cache hash
+	 */
+	public function test_calculate_shipping_for_package_allows_extensions_to_ignore_tax_address() {
+		update_option( 'woocommerce_shipping_debug_mode', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		WC()->session->__unset( 'shipping_for_package_0' );
+
+		$filter_calls          = 0;
+		$filter                = $this->get_package_rates_counter( $filter_calls );
+		$ignored_fields_filter = function ( array $ignored_fields ): array {
+			$ignored_fields[] = 'tax_location';
+			return $ignored_fields;
+		};
+		$package               = $this->get_package_hash_test_package();
+
+		add_filter( 'woocommerce_package_rates', $filter, 10 );
+		add_filter( 'woocommerce_shipping_package_hash_ignored_fields', $ignored_fields_filter );
+
+		$this->set_customer_tax_location( 'US', 'CA' );
+		$this->sut->calculate_shipping_for_package( $package );
+
+		$this->set_customer_tax_location( 'GB', '' );
+		$this->sut->calculate_shipping_for_package( $package );
+
+		$this->assertSame( 1, $filter_calls );
+
+		remove_filter( 'woocommerce_shipping_package_hash_ignored_fields', $ignored_fields_filter );
+		remove_filter( 'woocommerce_package_rates', $filter, 10 );
+	}
+
 	/**
 	 * Data provider for test_package_rates_filter_error_handling.
 	 *
@@ -400,6 +474,17 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Give the customer a shipping address that taxes apply to.
+	 *
+	 * @param string $country Country code.
+	 * @param string $state   State code.
+	 */
+	private function set_customer_tax_location( string $country, string $state ): void {
+		update_option( 'woocommerce_tax_based_on', 'shipping' );
+		WC()->customer->set_shipping_location( $country, $state, '00000', 'Test City' );
+	}
+
 	/**
 	 * Get a package rates filter that counts recalculations.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreFilterQueriesTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreFilterQueriesTest.php
index f864244e06a..0c154337e01 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreFilterQueriesTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreFilterQueriesTest.php
@@ -11,6 +11,7 @@ use WC_Helper_Customer;
 use WC_Helper_Order;
 use WC_Helper_Queue;
 use WC_Helper_Reports;
+use WC_Helper_Shipping;
 use WC_Order_Item_Product;
 use WC_Product_Simple;

@@ -216,6 +217,7 @@ class DataStoreFilterQueriesTest extends OrdersStatsTestCase {
 		self::$primary_product_ids = array();

 		WC_Helper_Reports::reset_stats_dbs();
+		WC_Helper_Shipping::delete_simple_flat_rate();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreZeroFillTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreZeroFillTest.php
index 83ba28256cc..91c34d9c793 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreZeroFillTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreZeroFillTest.php
@@ -10,6 +10,7 @@ use WC_Helper_Customer;
 use WC_Helper_Order;
 use WC_Helper_Queue;
 use WC_Helper_Reports;
+use WC_Helper_Shipping;
 use WC_Product_Simple;

 /**
@@ -127,6 +128,7 @@ class DataStoreZeroFillTest extends OrdersStatsTestCase {
 		}

 		WC_Helper_Reports::reset_stats_dbs();
+		WC_Helper_Shipping::delete_simple_flat_rate();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
index d882fd06d79..80bfea5a4d9 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
@@ -334,6 +334,7 @@ class ShippingControllerTest extends \WC_Unit_Test_Case {
 	public function test_filter_taxable_address_returns_chosen_pickup_location_address(): void {
 		$this->save_pickup_locations();
 		WC()->session->set( 'chosen_shipping_methods', array( 'pickup_location:1' ) );
+		WC()->session->set( 'previous_shipping_methods', array( array( 'pickup_location:1' ) ) );

 		$this->assertSame(
 			array( 'US', 'NY', '10001', 'New York' ),
@@ -361,6 +362,7 @@ class ShippingControllerTest extends \WC_Unit_Test_Case {
 			)
 		);
 		WC()->session->set( 'chosen_shipping_methods', array( 'pickup_location:0' ) );
+		WC()->session->set( 'previous_shipping_methods', array( array( 'pickup_location:0' ) ) );

 		$this->assertSame(
 			array( 'FR', '', '75001', 'Paris' ),
diff --git a/plugins/woocommerce/tests/php/src/Utilities/ShippingUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/ShippingUtilTest.php
new file mode 100644
index 00000000000..f84ed24c232
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Utilities/ShippingUtilTest.php
@@ -0,0 +1,454 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Utilities;
+
+use Automattic\WooCommerce\Utilities\ShippingUtil;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ShippingUtil class.
+ */
+class ShippingUtilTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Most runs of the package filter before a test stops the code it runs from the filter.
+	 */
+	private const PACKAGE_FILTER_RUN_CAP = 5;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->reset_cart_package_keys();
+		WC()->shipping()->unregister_shipping_methods();
+		foreach ( array( 'flat_rate', 'free_shipping', 'international_delivery', 'local_delivery', 'local_pickup' ) as $legacy_method ) {
+			delete_option( 'woocommerce_' . $legacy_method . '_settings' );
+		}
+		\WC_Cache_Helper::get_transient_version( 'shipping', true );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		try {
+			$this->reset_cart_package_keys();
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * Forget the package keys kept from the last build, so each test starts without them.
+	 */
+	private function reset_cart_package_keys(): void {
+		$property = new \ReflectionProperty( ShippingUtil::class, 'cart_package_keys' );
+		$property->setAccessible( true );
+		$property->setValue( null, null );
+	}
+
+	/**
+	 * Count the runs of the `woocommerce_cart_shipping_packages` filter after all other callbacks.
+	 *
+	 * @param int $calls Variable that receives the number of runs.
+	 */
+	private function count_package_builds( int &$calls ): void {
+		add_filter(
+			'woocommerce_cart_shipping_packages',
+			function ( $packages ) use ( &$calls ) {
+				++$calls;
+				return $packages;
+			},
+			PHP_INT_MAX
+		);
+	}
+
+	/**
+	 * Run a callable from the `woocommerce_cart_shipping_packages` filter and count the runs.
+	 *
+	 * The callable does not run after the cap, so a build that repeats itself ends in a failed
+	 * assertion instead of an exhausted stack.
+	 *
+	 * @param callable $inner Code to run from the filter.
+	 * @param int      $calls Variable that receives the number of filter runs.
+	 */
+	private function run_in_package_filter( callable $inner, int &$calls ): void {
+		add_filter(
+			'woocommerce_cart_shipping_packages',
+			function ( $packages ) use ( $inner, &$calls ) {
+				++$calls;
+				if ( $calls < self::PACKAGE_FILTER_RUN_CAP ) {
+					$inner();
+				}
+				return $packages;
+			}
+		);
+	}
+
+	/**
+	 * @testdox is_shipping_rate_listed only accepts a rate listed for the same package key.
+	 *
+	 * @testWith [ 0, "flat_rate:1", true ]
+	 *           [ 0, "local_pickup:2", false ]
+	 *           [ 1, "flat_rate:1", false ]
+	 *           [ 0, "123", true ]
+	 *           [ 0, 123, false ]
+	 *
+	 * @param int|string $package_key Package key to check.
+	 * @param mixed      $rate_id     Rate id to check.
+	 * @param bool       $expected    Expected result.
+	 */
+	public function test_is_shipping_rate_listed( $package_key, $rate_id, bool $expected ): void {
+		WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'flat_rate:1', 123 ) ) );
+
+		$this->assertSame( $expected, ShippingUtil::is_shipping_rate_listed( $package_key, $rate_id ) );
+	}
+
+	/**
+	 * @testdox is_shipping_rate_listed returns false when no rates were listed.
+	 */
+	public function test_is_shipping_rate_listed_without_listed_rates(): void {
+		WC()->session->set( 'previous_shipping_methods', null );
+
+		$this->assertFalse( ShippingUtil::is_shipping_rate_listed( 0, 'flat_rate:1' ) );
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods drops choices whose rate was not listed for their key.
+	 */
+	public function test_filter_chosen_shipping_methods_drops_unlisted_rates(): void {
+		WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'flat_rate:1' ) ) );
+
+		$this->assertSame(
+			array( 0 => 'flat_rate:1' ),
+			ShippingUtil::filter_chosen_shipping_methods(
+				array(
+					0       => 'flat_rate:1',
+					'extra' => 'flat_rate:1',
+				)
+			),
+			'A key with no listed rates must be dropped.'
+		);
+		$this->assertSame(
+			array(),
+			ShippingUtil::filter_chosen_shipping_methods( array( 0 => 'local_pickup:2' ) ),
+			'A rate that was not listed for the key must be dropped.'
+		);
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods keeps a listed pickup rate only for a shipping package of the cart.
+	 */
+	public function test_filter_chosen_shipping_methods_checks_package_key_for_pickup(): void {
+		WC()->session->set(
+			'previous_shipping_methods',
+			array(
+				0       => array( 'local_pickup:2' ),
+				'other' => array( 'local_pickup:2', 'flat_rate:1' ),
+			)
+		);
+
+		$this->assertSame(
+			array( 0 => 'local_pickup:2' ),
+			ShippingUtil::filter_chosen_shipping_methods(
+				array(
+					0       => 'local_pickup:2',
+					'other' => 'local_pickup:2',
+				)
+			),
+			'A pickup rate for a key that is not a cart package must be dropped.'
+		);
+		$this->assertSame(
+			array( 'other' => 'flat_rate:1' ),
+			ShippingUtil::filter_chosen_shipping_methods( array( 'other' => 'flat_rate:1' ) ),
+			'A listed rate that is not pickup does not need a cart package.'
+		);
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods builds the cart packages one time for the same cart state.
+	 */
+	public function test_filter_chosen_shipping_methods_builds_packages_once_for_same_cart_state(): void {
+		WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+		$chosen = array(
+			0       => 'local_pickup:2',
+			'other' => 'local_pickup:2',
+		);
+		$calls  = 0;
+		$this->count_package_builds( $calls );
+
+		for ( $i = 0; $i < 3; $i++ ) {
+			$this->assertSame( array( 0 => 'local_pickup:2' ), ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+		}
+
+		$this->assertSame( 1, $calls, 'The packages must be built one time for three calls with the same cart state.' );
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods builds the cart packages one time when a package filter reads the taxable address.
+	 */
+	public function test_filter_chosen_shipping_methods_builds_packages_once_when_package_filter_reads_taxable_address(): void {
+		update_option( 'woocommerce_default_country', 'US:CA' );
+		update_option( 'woocommerce_tax_based_on', 'shipping' );
+		WC()->customer->set_shipping_country( 'GB' );
+		WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+		WC()->session->set( 'chosen_shipping_methods', array( 0 => 'local_pickup:2' ) );
+		$calls   = 0;
+		$address = null;
+		$this->run_in_package_filter(
+			function () use ( &$address ) {
+				$address = WC()->customer->get_taxable_address();
+			},
+			$calls
+		);
+
+		$result = ShippingUtil::filter_chosen_shipping_methods( array( 0 => 'local_pickup:2' ) );
+
+		$this->assertSame( 1, $calls, 'The packages must be built one time when a package filter reads the taxable address.' );
+		$this->assertSame( array( 0 => 'local_pickup:2' ), $result, 'The listed pickup rate must be kept.' );
+		$this->assertSame( 'US', $address[0], 'While the packages are built, the chosen pickup must still give the store address.' );
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods builds the cart packages one time when a package filter reads a taxed price.
+	 */
+	public function test_filter_chosen_shipping_methods_builds_packages_once_when_package_filter_reads_taxed_price(): void {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_default_country', 'US:CA' );
+		update_option( 'woocommerce_tax_based_on', 'shipping' );
+		WC()->customer->set_shipping_country( 'GB' );
+		\WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country' => 'US',
+				'tax_rate'         => '10.0000',
+				'tax_rate_name'    => 'US',
+			)
+		);
+		\WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country' => 'GB',
+				'tax_rate'         => '20.0000',
+				'tax_rate_name'    => 'GB',
+			)
+		);
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 10 ) );
+		WC()->cart->add_to_cart( $product->get_id() );
+		WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+		WC()->session->set( 'chosen_shipping_methods', array( 0 => 'local_pickup:2' ) );
+		$calls = 0;
+		$price = null;
+		$this->run_in_package_filter(
+			function () use ( $product, &$price ) {
+				$price = wc_get_price_including_tax( $product );
+			},
+			$calls
+		);
+
+		$result = ShippingUtil::filter_chosen_shipping_methods( array( 0 => 'local_pickup:2' ) );
+
+		$this->assertSame( 1, $calls, 'The packages must be built one time when a package filter reads a taxed price.' );
+		$this->assertSame( array( 0 => 'local_pickup:2' ), $result, 'The listed pickup rate must be kept.' );
+		$this->assertSame( 11.0, $price, 'While the packages are built, the chosen pickup must still give the store tax rate.' );
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods checks the package key again once the cart packages are built.
+	 */
+	public function test_filter_chosen_shipping_methods_checks_package_key_once_packages_are_built(): void {
+		WC()->session->set(
+			'previous_shipping_methods',
+			array(
+				0       => array( 'local_pickup:2' ),
+				'other' => array( 'local_pickup:2' ),
+			)
+		);
+		$chosen = array(
+			0       => 'local_pickup:2',
+			'other' => 'local_pickup:2',
+		);
+		$calls  = 0;
+		$nested = null;
+		$this->run_in_package_filter(
+			function () use ( $chosen, &$nested ) {
+				$nested = ShippingUtil::filter_chosen_shipping_methods( $chosen );
+			},
+			$calls
+		);
+
+		$result = ShippingUtil::filter_chosen_shipping_methods( $chosen );
+
+		$this->assertSame( 1, $calls );
+		$this->assertSame( $chosen, $nested, 'While the packages are built, every listed pickup must be kept.' );
+		$this->assertSame( array( 0 => 'local_pickup:2' ), $result, 'The outer call must drop the pickup for a key that is not a cart package.' );
+		$this->assertSame( array( 0 => 'local_pickup:2' ), ShippingUtil::filter_chosen_shipping_methods( $chosen ), 'A later call must drop the pickup for a key that is not a cart package.' );
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods forgets the cart state when the package build throws.
+	 */
+	public function test_filter_chosen_shipping_methods_forgets_cart_state_when_package_build_throws(): void {
+		WC()->session->set(
+			'previous_shipping_methods',
+			array(
+				0       => array( 'local_pickup:2' ),
+				'other' => array( 'local_pickup:2' ),
+			)
+		);
+		$chosen = array(
+			0       => 'local_pickup:2',
+			'other' => 'local_pickup:2',
+		);
+		$fail   = true;
+		add_filter(
+			'woocommerce_cart_shipping_packages',
+			function ( $packages ) use ( &$fail ) {
+				if ( $fail ) {
+					throw new \RuntimeException( 'Package filter failed.' );
+				}
+				return $packages;
+			}
+		);
+
+		$caught = null;
+		try {
+			ShippingUtil::filter_chosen_shipping_methods( $chosen );
+		} catch ( \RuntimeException $e ) {
+			$caught = $e;
+		}
+
+		$this->assertInstanceOf( \RuntimeException::class, $caught, 'The exception from the package filter must reach the caller.' );
+		$property = new \ReflectionProperty( ShippingUtil::class, 'cart_package_keys' );
+		$property->setAccessible( true );
+		$this->assertNull( $property->getValue(), 'A failed build must not leave an entry for the cart state.' );
+
+		$fail = false;
+		$this->assertSame( array( 0 => 'local_pickup:2' ), ShippingUtil::filter_chosen_shipping_methods( $chosen ), 'The next call must build the packages and check the package key.' );
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods builds the cart packages again when the cart items change.
+	 */
+	public function test_filter_chosen_shipping_methods_rebuilds_packages_when_cart_items_change(): void {
+		WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+		$chosen = array( 0 => 'local_pickup:2' );
+		$calls  = 0;
+		$this->count_package_builds( $calls );
+
+		ShippingUtil::filter_chosen_shipping_methods( $chosen );
+		$this->assertSame( 1, $calls );
+
+		$product  = \WC_Helper_Product::create_simple_product();
+		$item_key = WC()->cart->add_to_cart( $product->get_id() );
+		$calls    = 0;
+		$this->assertSame( $chosen, ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+		$this->assertSame( 1, $calls, 'The packages must be built again after an item is added.' );
+		ShippingUtil::filter_chosen_shipping_methods( $chosen );
+		$this->assertSame( 1, $calls, 'The packages must not be built again for the same items.' );
+
+		WC()->cart->set_quantity( $item_key, 2, false );
+		$calls = 0;
+		$this->assertSame( $chosen, ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+		$this->assertSame( 1, $calls, 'The packages must be built again after a quantity changes.' );
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods builds the cart packages again when the shipping address changes.
+	 */
+	public function test_filter_chosen_shipping_methods_rebuilds_packages_when_shipping_address_changes(): void {
+		// Other test classes leave their postcode on the shared customer, so start from a known one.
+		WC()->customer->set_shipping_postcode( '90001' );
+		WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+		$chosen = array( 0 => 'local_pickup:2' );
+		$calls  = 0;
+		$this->count_package_builds( $calls );
+
+		ShippingUtil::filter_chosen_shipping_methods( $chosen );
+		$this->assertSame( 1, $calls );
+
+		WC()->customer->set_shipping_postcode( '90210' );
+		$calls = 0;
+		$this->assertSame( $chosen, ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+		$this->assertSame( 1, $calls, 'The packages must be built again after the shipping address changes.' );
+		ShippingUtil::filter_chosen_shipping_methods( $chosen );
+		$this->assertSame( 1, $calls, 'The packages must not be built again for the same address.' );
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods builds the cart packages again for another cart object.
+	 */
+	public function test_filter_chosen_shipping_methods_rebuilds_packages_for_another_cart_object(): void {
+		WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+		$chosen = array( 0 => 'local_pickup:2' );
+		$calls  = 0;
+		$this->count_package_builds( $calls );
+
+		ShippingUtil::filter_chosen_shipping_methods( $chosen );
+		$this->assertSame( 1, $calls );
+
+		$original_cart = WC()->cart;
+		try {
+			WC()->cart = clone $original_cart;
+			$calls     = 0;
+			$this->assertSame( $chosen, ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+			$this->assertSame( 1, $calls, 'The packages must be built again for another cart object.' );
+		} finally {
+			WC()->cart = $original_cart;
+		}
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods builds the cart packages again for each totals calculation.
+	 */
+	public function test_filter_chosen_shipping_methods_rebuilds_packages_for_each_totals_calculation(): void {
+		$recurring_key = '2026_11_30_monthly_0';
+		$use_recurring = false;
+		add_filter(
+			'woocommerce_cart_shipping_packages',
+			function ( $packages ) use ( &$use_recurring, $recurring_key ) {
+				return $use_recurring ? array( $recurring_key => $packages[0] ) : $packages;
+			}
+		);
+		$listed = array(
+			0              => array( 'local_pickup:2' ),
+			$recurring_key => array( 'local_pickup:2' ),
+		);
+		$chosen = array(
+			0              => 'local_pickup:2',
+			$recurring_key => 'local_pickup:2',
+		);
+		WC()->session->set( 'previous_shipping_methods', $listed );
+
+		$this->assertSame(
+			array( 0 => 'local_pickup:2' ),
+			ShippingUtil::filter_chosen_shipping_methods( $chosen ),
+			'Before the recurring calculation, only the main package key must be kept.'
+		);
+
+		$use_recurring = true;
+		do_action( 'woocommerce_before_calculate_totals', WC()->cart );
+		$this->assertSame(
+			array( $recurring_key => 'local_pickup:2' ),
+			ShippingUtil::filter_chosen_shipping_methods( $chosen ),
+			'During the recurring calculation, only the recurring package key must be kept.'
+		);
+
+		$use_recurring = false;
+		do_action( 'woocommerce_after_calculate_totals', WC()->cart );
+		// WC_Cart_Session::set_session() runs on this action and clears the listed rates for a cart without shippable items.
+		WC()->session->set( 'previous_shipping_methods', $listed );
+		$this->assertSame(
+			array( 0 => 'local_pickup:2' ),
+			ShippingUtil::filter_chosen_shipping_methods( $chosen ),
+			'After the recurring calculation, only the main package key must be kept.'
+		);
+	}
+
+	/**
+	 * @testdox filter_chosen_shipping_methods returns an empty array for a value that is not an array.
+	 */
+	public function test_filter_chosen_shipping_methods_with_invalid_input(): void {
+		$this->assertSame( array(), ShippingUtil::filter_chosen_shipping_methods( 'local_pickup:2' ) );
+	}
+}