Commit b922f99f235 for woocommerce

commit b922f99f2354c29620c37ee9225c8cb743c1fc31
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date:   Wed Sep 16 13:34:39 2026 +0300

    Fix missing shipping rates when extensions remove free shipping (#68707)

    * Fix missing shipping rates when extensions remove free shipping

    The hide-when-free rule ran before woocommerce_package_rates, so paid rates
    were stripped based on a free shipping option a later filter could still
    remove. Running it after the filter means rates can now arrive from filter
    callbacks, so the local pickup lookup needs a null guard.

    * Validate the response from the hook and add tests for it.

    * Add docblock for PHPStan

    * Update the baseline

diff --git a/plugins/woocommerce/changelog/63300-hide-rates-when-free-shipping-filtered b/plugins/woocommerce/changelog/63300-hide-rates-when-free-shipping-filtered
new file mode 100644
index 00000000000..c9965bd0cac
--- /dev/null
+++ b/plugins/woocommerce/changelog/63300-hide-rates-when-free-shipping-filtered
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Apply "Hide shipping rates when free shipping is available" after the woocommerce_package_rates filter, so extensions that remove free shipping no longer leave the customer with no rates.
diff --git a/plugins/woocommerce/includes/class-wc-shipping-rate.php b/plugins/woocommerce/includes/class-wc-shipping-rate.php
index c49b44e27ca..1e79770d548 100644
--- a/plugins/woocommerce/includes/class-wc-shipping-rate.php
+++ b/plugins/woocommerce/includes/class-wc-shipping-rate.php
@@ -16,6 +16,9 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;

 /**
  * Shipping rate class.
+ *
+ * @property string $id        Shipping rate ID.
+ * @property string $method_id Shipping method ID.
  */
 class WC_Shipping_Rate implements JsonSerializable {

diff --git a/plugins/woocommerce/includes/class-wc-shipping.php b/plugins/woocommerce/includes/class-wc-shipping.php
index 1c9e335f90e..6bcd3fafc53 100644
--- a/plugins/woocommerce/includes/class-wc-shipping.php
+++ b/plugins/woocommerce/includes/class-wc-shipping.php
@@ -358,7 +358,27 @@ class WC_Shipping {
 				}
 			}

-			// Hide shipping rates when free shipping is available.
+			/**
+			 * Filter the calculated shipping rates.
+			 *
+			 * @see https://gist.github.com/woogists/271654709e1d27648546e83253c1a813 for cache invalidation methods.
+			 * @since 2.0.0
+			 * @param array $package['rates'] Package rates.
+			 * @param array $package Package of cart items.
+			 */
+			$package['rates'] = apply_filters( 'woocommerce_package_rates', $package['rates'], $package );
+
+			// Package rates should be an array, if it was filtered into a non-array, reset it. Don't reset to the
+			// unfiltered value, as e.g. a 3pd could have set it to "false" to remove rates.
+			if ( ! is_array( $package['rates'] ) ) {
+				$package['rates'] = array();
+			}
+
+			$package['rates'] = array_filter( $package['rates'], static fn( $rate ) => $rate instanceof WC_Shipping_Rate );
+
+			// Hide shipping rates when free shipping is available. Runs after the woocommerce_package_rates filter
+			// so that free shipping only counts as available if it survived filtering; otherwise an extension that
+			// removes free shipping would leave the customer with no rates at all.
 			if ( 'yes' === get_option( 'woocommerce_shipping_hide_rates_when_free', 'no' ) ) {
 				$free_shipping = array();
 				$local_pickup  = array();
@@ -369,7 +389,10 @@ class WC_Shipping {
 						continue;
 					}

-					if ( $this->shipping_methods[ $rate->method_id ]->supports( 'local-pickup' ) || 'local_pickup' === $rate->method_id ) {
+					// The rate may have been added by a filter callback, so its method is not necessarily registered here.
+					$rate_method = $this->shipping_methods[ $rate->method_id ] ?? null;
+
+					if ( 'local_pickup' === $rate->method_id || ( $rate_method && $rate_method->supports( 'local-pickup' ) ) ) {
 						$local_pickup[ $rate->id ] = $rate;
 					}
 				}
@@ -379,22 +402,6 @@ class WC_Shipping {
 				}
 			}

-			/**
-			 * Filter the calculated shipping rates.
-			 *
-			 * @see https://gist.github.com/woogists/271654709e1d27648546e83253c1a813 for cache invalidation methods.
-			 * @since 2.0.0
-			 * @param array $package['rates'] Package rates.
-			 * @param array $package Package of cart items.
-			 */
-			$package['rates'] = apply_filters( 'woocommerce_package_rates', $package['rates'], $package );
-
-			// Package rates should be an array, if it was filtered into a non-array, reset it. Don't reset to the
-			// unfiltered value, as e.g. a 3pd could have set it to "false" to remove rates.
-			if ( ! is_array( $package['rates'] ) ) {
-				$package['rates'] = array();
-			}
-
 			// Store in session to avoid recalculation.
 			WC()->session->set(
 				$wc_session_key,
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 633efd3278d..66e9c7fbd68 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -14911,12 +14911,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-shipping.php

-		-
-			message: '#^Offset mixed might not exist on array\|null\.$#'
-			identifier: offsetAccess.notFound
-			count: 1
-			path: includes/class-wc-shipping.php
-
 		-
 			message: '#^One or more @param tags has an invalid name or invalid syntax\.$#'
 			identifier: phpDoc.parseError
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 535bf37d22e..afd60123b46 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
@@ -42,8 +42,9 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 	 * @param string $option_value Option value for woocommerce_shipping_hide_rates_when_free.
 	 * @param array  $shipping_methods Available shipping methods.
 	 * @param array  $expected_rates Expected rates.
+	 * @param array  $hidden_rates Rates expected to be hidden.
 	 */
-	public function test_calculate_shipping_for_hide_rates_when_free( string $option_value, array $shipping_methods, array $expected_rates ) {
+	public function test_calculate_shipping_for_hide_rates_when_free( string $option_value, array $shipping_methods, array $expected_rates, array $hidden_rates ) {
 		update_option( 'woocommerce_shipping_hide_rates_when_free', $option_value );

 		$shipping_methods_hook = fn () => $shipping_methods;
@@ -66,7 +67,100 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 			$this->assertArrayHasKey( $rate, $result['rates'] );
 		}

+		foreach ( $hidden_rates as $rate ) {
+			$this->assertArrayNotHasKey( $rate, $result['rates'] );
+		}
+
+		remove_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+	}
+
+	/**
+	 * @testdox paid rates stay visible when the package rates filter removes free shipping.
+	 */
+	public function test_hide_rates_when_free_respects_free_shipping_removed_by_filter() {
+		update_option( 'woocommerce_shipping_hide_rates_when_free', 'yes' );
+
+		$shipping_methods_hook = fn () => array( new WC_Shipping_Flat_Rate( 1 ), new WC_Shipping_Free_Shipping( 1 ) );
+		$remove_free_shipping  = function ( $rates ) {
+			unset( $rates['free_shipping:1'] );
+			return $rates;
+		};
+
+		add_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+		add_filter( 'woocommerce_package_rates', $remove_free_shipping );
+
+		$result = $this->sut->calculate_shipping_for_package( $this->get_hide_rates_test_package() );
+
+		remove_filter( 'woocommerce_package_rates', $remove_free_shipping );
 		remove_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+
+		$this->assertArrayHasKey( 'flat_rate:1', $result['rates'], 'Paid rates should remain when a filter removes free shipping.' );
+	}
+
+	/**
+	 * @testdox a paid rate added by the package rates filter for an unregistered method is hidden without errors.
+	 */
+	public function test_hide_rates_when_free_handles_filter_added_rate_for_unregistered_method() {
+		update_option( 'woocommerce_shipping_hide_rates_when_free', 'yes' );
+
+		$shipping_methods_hook = fn () => array( new WC_Shipping_Free_Shipping( 1 ) );
+		$add_unregistered_rate = function ( $rates ) {
+			$rates['custom_carrier:1'] = new WC_Shipping_Rate( 'custom_carrier:1', 'Custom Carrier', 5, array(), 'custom_carrier' );
+			return $rates;
+		};
+
+		add_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+		add_filter( 'woocommerce_package_rates', $add_unregistered_rate );
+
+		$result = $this->sut->calculate_shipping_for_package( $this->get_hide_rates_test_package() );
+
+		remove_filter( 'woocommerce_package_rates', $add_unregistered_rate );
+		remove_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+
+		$this->assertSame(
+			array( 'free_shipping:1' ),
+			array_keys( $result['rates'] ),
+			'Free shipping should hide a paid rate added by a filter for a method that is not registered.'
+		);
+	}
+
+	/**
+	 * @testdox filtered rates that are not WC_Shipping_Rate instances are removed.
+	 *
+	 * @dataProvider provide_invalid_filtered_rates
+	 * @param mixed $invalid_rate Invalid filtered rate.
+	 */
+	public function test_calculate_shipping_rejects_invalid_filtered_rates( $invalid_rate ): void {
+		$valid_rate = new WC_Shipping_Rate( 'flat_rate:1', 'Flat rate', 5, array(), 'flat_rate' );
+
+		add_filter(
+			'woocommerce_package_rates',
+			fn () => array(
+				'flat_rate:1' => $valid_rate,
+				'invalid'     => $invalid_rate,
+			)
+		);
+
+		$result = $this->sut->calculate_shipping_for_package( $this->get_hide_rates_test_package() );
+
+		$this->assertSame( array( 'flat_rate:1' => $valid_rate ), $result['rates'], 'Only WC_Shipping_Rate instances should remain.' );
+	}
+
+	/**
+	 * Invalid values returned by the package rates filter.
+	 *
+	 * @return array
+	 */
+	public static function provide_invalid_filtered_rates(): array {
+		return array(
+			'null'     => array( null ),
+			'stdClass' => array(
+				(object) array(
+					'id'        => 'local_pickup:1',
+					'method_id' => 'local_pickup',
+				),
+			),
+		);
 	}

 	/**
@@ -335,6 +429,23 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 		};
 	}

+	/**
+	 * Get a package for hide-rates-when-free tests.
+	 *
+	 * @return array
+	 */
+	private function get_hide_rates_test_package(): array {
+		return array(
+			'contents'      => array(),
+			'contents_cost' => 10,
+			'destination'   => array(
+				'country'  => 'US',
+				'state'    => 'CA',
+				'postcode' => '00000',
+			),
+		);
+	}
+
 	/**
 	 * Get a package for shipping hash tests.
 	 *
@@ -392,16 +503,19 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
 				'no',
 				array( $flat_rate, $free_shipping, $local_pickup, $custom_pickup ),
 				array( 'flat_rate:1', 'free_shipping:1', 'local_pickup:1', 'custom_pickup:1' ),
+				array(),
 			),
 			'hide enabled - with free shipping'    => array(
 				'yes',
 				array( $flat_rate, $free_shipping, $local_pickup, $custom_pickup ),
 				array( 'free_shipping:1', 'local_pickup:1', 'custom_pickup:1' ),
+				array( 'flat_rate:1' ),
 			),
 			'hide enabled - without free shipping' => array(
 				'yes',
 				array( $flat_rate, $local_pickup, $custom_pickup ),
 				array( 'flat_rate:1', 'local_pickup:1', 'custom_pickup:1' ),
+				array(),
 			),
 		);
 	}