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.