Commit 97baf096a84 for woocommerce

commit 97baf096a842a8aed872affea1cb842d66203a83
Author: Sulaiman Dauda <abiodauda@gmail.com>
Date:   Sat Nov 7 13:59:19 2026 +0000

    Treat an empty min_price or max_price as no price limit (#70138)

diff --git a/plugins/woocommerce/changelog/fix-69987-empty-max-price b/plugins/woocommerce/changelog/fix-69987-empty-max-price
new file mode 100644
index 00000000000..9aaa1815af0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-69987-empty-max-price
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Treat an empty min_price or max_price in shop URLs and the products REST API as no limit; e.g. ?min_price=30&max_price= now returns products from 30 up instead of none. With a blank price, the price filter widget no longer shows a maximum of 0 and the shop no longer hides subcategories.
diff --git a/plugins/woocommerce/includes/class-wc-query.php b/plugins/woocommerce/includes/class-wc-query.php
index 49abeac042b..c545de628de 100644
--- a/plugins/woocommerce/includes/class-wc-query.php
+++ b/plugins/woocommerce/includes/class-wc-query.php
@@ -857,15 +857,15 @@ class WC_Query {
 		 */
 		$enable_filtering = apply_filters( 'woocommerce_enable_post_clause_filtering', $wp_query->is_main_query(), $wp_query );

-		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
-		if ( ! $enable_filtering || ( ! isset( $_GET['max_price'] ) && ! isset( $_GET['min_price'] ) ) ) {
+		$min_filter = self::get_price_filter( 'min_price' );
+		$max_filter = self::get_price_filter( 'max_price' );
+
+		if ( ! $enable_filtering || ( null === $min_filter && null === $max_filter ) ) {
 			return $args;
 		}

-		// phpcs:disable WordPress.Security.NonceVerification.Recommended
-		$current_min_price = isset( $_GET['min_price'] ) ? floatval( wp_unslash( $_GET['min_price'] ) ) : 0;
-		$current_max_price = isset( $_GET['max_price'] ) ? floatval( wp_unslash( $_GET['max_price'] ) ) : PHP_INT_MAX;
-		// phpcs:enable WordPress.Security.NonceVerification.Recommended
+		$current_min_price = $min_filter ?? 0;
+		$current_max_price = $max_filter ?? PHP_INT_MAX;

 		/**
 		 * Adjust if the store taxes are not displayed how they are stored.
@@ -1111,6 +1111,27 @@ class WC_Query {
 		return implode( ' AND ', $sql );
 	}

+	/**
+	 * Get a price filter from the query string. A blank value, as sent by an empty form field, means no limit.
+	 *
+	 * @internal
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $key Either 'min_price' or 'max_price'.
+	 * @return float|null The price, or null when there is no limit.
+	 */
+	public static function get_price_filter( $key ) {
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only public catalog filter, converted to a number.
+		$value = isset( $_GET[ $key ] ) ? wp_unslash( $_GET[ $key ] ) : null;
+
+		if ( null === $value || ( is_scalar( $value ) && '' === trim( (string) $value ) ) ) {
+			return null;
+		}
+
+		return floatval( $value );
+	}
+
 	/**
 	 * Get an array of attributes and terms selected with the layered nav widget.
 	 *
diff --git a/plugins/woocommerce/includes/wc-conditional-functions.php b/plugins/woocommerce/includes/wc-conditional-functions.php
index 4c2539b9e0b..dba3fa0e056 100644
--- a/plugins/woocommerce/includes/wc-conditional-functions.php
+++ b/plugins/woocommerce/includes/wc-conditional-functions.php
@@ -362,7 +362,7 @@ if ( ! function_exists( 'is_filtered' ) ) {
 		 * @since 2.1.0
 		 * @param bool $is_filtered Whether a product filter is active.
 		 */
-		return apply_filters( 'woocommerce_is_filtered', ( count( WC_Query::get_layered_nav_chosen_attributes() ) > 0 || isset( $_GET['max_price'] ) || isset( $_GET['min_price'] ) || isset( $_GET['rating_filter'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only presence checks on public catalog filter parameters; nothing is written.
+		return apply_filters( 'woocommerce_is_filtered', ( count( WC_Query::get_layered_nav_chosen_attributes() ) > 0 || null !== WC_Query::get_price_filter( 'max_price' ) || null !== WC_Query::get_price_filter( 'min_price' ) || isset( $_GET['rating_filter'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only presence check on a public catalog filter parameter; nothing is written.
 	}
 }

diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index e4f0bef7f0d..fe8ef32771d 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -2153,8 +2153,8 @@ function wc_products_array_orderby_price( $a, $b ) {
  * @return array
  */
 function wc_get_min_max_price_meta_query( $args ) {
-	$current_min_price = isset( $args['min_price'] ) ? floatval( $args['min_price'] ) : 0;
-	$current_max_price = isset( $args['max_price'] ) ? floatval( $args['max_price'] ) : PHP_INT_MAX;
+	$current_min_price = isset( $args['min_price'] ) && '' !== $args['min_price'] ? floatval( $args['min_price'] ) : 0;
+	$current_max_price = isset( $args['max_price'] ) && '' !== $args['max_price'] ? floatval( $args['max_price'] ) : PHP_INT_MAX;

 	/**
 	 * Filters the meta query used to match products by price.
diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-price-filter.php b/plugins/woocommerce/includes/widgets/class-wc-widget-price-filter.php
index e2c3b51053e..74703fdeb82 100644
--- a/plugins/woocommerce/includes/widgets/class-wc-widget-price-filter.php
+++ b/plugins/woocommerce/includes/widgets/class-wc-widget-price-filter.php
@@ -82,7 +82,7 @@ class WC_Widget_Price_Filter extends WC_Widget {
 		}

 		// If there are not posts and we're not filtering, hide the widget.
-		if ( ! WC()->query->get_main_query()->post_count && ! isset( $_GET['min_price'] ) && ! isset( $_GET['max_price'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only filter; values are unslashed and converted to numbers.
+		if ( ! WC()->query->get_main_query()->post_count && null === WC_Query::get_price_filter( 'min_price' ) && null === WC_Query::get_price_filter( 'max_price' ) ) {
 			return;
 		}

@@ -117,8 +117,10 @@ class WC_Widget_Price_Filter extends WC_Widget {
 			return;
 		}

-		$current_min_price = isset( $_GET['min_price'] ) ? floor( floatval( wp_unslash( $_GET['min_price'] ) ) / $step ) * $step : $min_price; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only filter; values are unslashed and converted to numbers.
-		$current_max_price = isset( $_GET['max_price'] ) ? ceil( floatval( wp_unslash( $_GET['max_price'] ) ) / $step ) * $step : $max_price; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only filter; values are unslashed and converted to numbers.
+		$min_filter        = WC_Query::get_price_filter( 'min_price' );
+		$max_filter        = WC_Query::get_price_filter( 'max_price' );
+		$current_min_price = null !== $min_filter ? floor( $min_filter / $step ) * $step : $min_price;
+		$current_max_price = null !== $max_filter ? ceil( $max_filter / $step ) * $step : $max_price;

 		$this->widget_start( $args, $instance );

diff --git a/plugins/woocommerce/tests/php/includes/class-wc-query-test.php b/plugins/woocommerce/tests/php/includes/class-wc-query-test.php
index 29d3d866ffb..cf55feedbc1 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-query-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-query-test.php
@@ -7,6 +7,30 @@ declare( strict_types = 1 );
  */
 class WC_Query_Test extends \WC_Unit_Test_Case {

+	/**
+	 * The query string before each test.
+	 *
+	 * @var array
+	 */
+	private $original_get;
+
+	/**
+	 * Saves the query string, which these tests change, and clears the layered nav cache that is_filtered() reads.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		WC_Query::reset_chosen_attributes();
+	}
+
+	/**
+	 * Restores the query string so the price params don't reach later tests.
+	 */
+	public function tearDown(): void {
+		$_GET = $this->original_get;
+		parent::tearDown();
+	}
+
 	/**
 	 * @testdox View order titles disclose the order number only to customers who can view the order.
 	 * @testWith ["owner", true, "Order #CUSTOM-123"]
@@ -79,6 +103,59 @@ class WC_Query_Test extends \WC_Unit_Test_Case {
 		$this->assertEquals( $expected, $args['where'] );
 	}

+	/**
+	 * @testdox 'price_filter_post_clauses' treats an empty 'min_price' or 'max_price' as if it were not in the query.
+	 *
+	 * @testWith [{"min_price": "100", "max_price": ""}]
+	 *           [{"min_price": "100", "max_price": " "}]
+	 *           [{"min_price": "", "max_price": ""}]
+	 *
+	 * @param array $params The price filter query string parameters.
+	 */
+	public function test_price_filter_post_clauses_ignores_empty_prices( array $params ) {
+		// phpcs:disable Squiz.Commenting
+		$wp_query = new class() {
+			public function is_main_query() {
+				return true;
+			}
+		};
+		// phpcs:enable Squiz.Commenting
+
+		$sut  = new WC_Query();
+		$args = array(
+			'join'  => '(JOIN CLAUSE)',
+			'where' => '(WHERE CLAUSE)',
+		);
+
+		$_GET     = array_filter(
+			$params,
+			function ( $value ) {
+				return '' !== trim( $value );
+			}
+		);
+		$expected = $sut->price_filter_post_clauses( $args, $wp_query );
+
+		$_GET = $params;
+		$this->assertSame( $expected, $sut->price_filter_post_clauses( $args, $wp_query ) );
+	}
+
+	/**
+	 * @testdox 'is_filtered' ignores a blank 'min_price' or 'max_price' and counts a set one.
+	 *
+	 * @testWith [{"max_price": ""}, false]
+	 *           [{"min_price": " ", "max_price": ""}, false]
+	 *           [{"max_price": "0"}, true]
+	 *           [{"min_price": "30", "max_price": ""}, true]
+	 *           [{"max_price": ["1"]}, true]
+	 *
+	 * @param array $params   The price filter query string parameters.
+	 * @param bool  $expected Whether products count as filtered.
+	 */
+	public function test_is_filtered_ignores_blank_prices( array $params, bool $expected ) {
+		$_GET = $params;
+		$this->assertSame( $expected, is_filtered() );
+	}
+
 	/**
 	 * @testdox Shop page can be set as the homepage on block themes.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index 5b918ab8ce2..4908474e2e8 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -393,6 +393,7 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {
 	 *           [{"max_price": "30"}, ["cheap"]]
 	 *           [{"min_price": "15", "max_price": "25"}, ["cheap"]]
 	 *           [{"min_price": "60"}, []]
+	 *           [{"min_price": "30", "max_price": ""}, ["expensive"]]
 	 *
 	 * @param array    $params   Price filter query params.
 	 * @param string[] $expected Keys of the products expected in the response.