Commit acd77061f5c for woocommerce
commit acd77061f5c6bba7c2326055f1de220f4f07e0dd
Author: Deepak Lalwani <deepak.lalwani81@gmail.com>
Date: Wed Apr 22 10:36:11 2026 +0530
Fix Hidden product search visibility (#63181)
* Fix Hidden product search visibility
* Fix search query to exclude hidden products from results
* Apply visibility exclusion inline on sitewide search
Replaces the get_tax_query( ..., true ) routing with an inline tax query
that only adds exclude-from-search. Going through get_tax_query() pulls
in side effects scoped to product archives — layered nav attribute
filters from $_GET, rating filters, and the
woocommerce_product_query_tax_query hook — which should not fire on
sitewide WP search results.
Co-authored-by: Brandon Kraft <public@brandonkraft.com>
diff --git a/plugins/woocommerce/changelog/fix-39837-hidden-product-search-visibility b/plugins/woocommerce/changelog/fix-39837-hidden-product-search-visibility
new file mode 100644
index 00000000000..42da204f50d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-39837-hidden-product-search-visibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Hidden product search visibility
diff --git a/plugins/woocommerce/includes/class-wc-query.php b/plugins/woocommerce/includes/class-wc-query.php
index 756e85ed491..b522f5fbec4 100644
--- a/plugins/woocommerce/includes/class-wc-query.php
+++ b/plugins/woocommerce/includes/class-wc-query.php
@@ -414,6 +414,25 @@ class WC_Query {
}
} elseif ( ! $q->is_post_type_archive( 'product' ) && ! $q->is_tax( get_object_taxonomies( 'product' ) ) ) {
// Only apply to product categories, the product post archive, the shop page, product tags, and product attribute taxonomies.
+ if ( $q->is_search() ) {
+ // Exclude products flagged as hidden from search (catalog_visibility = hidden or catalog).
+ // Applied inline rather than via get_tax_query() to avoid pulling in layered nav filters
+ // and the woocommerce_product_query_tax_query hook, which are scoped to product archives.
+ $product_visibility_terms = wc_get_product_visibility_term_ids();
+ $exclude_term_id = isset( $product_visibility_terms['exclude-from-search'] ) ? (int) $product_visibility_terms['exclude-from-search'] : 0;
+
+ if ( $exclude_term_id > 0 ) {
+ $existing_tax_query = $q->get( 'tax_query' );
+ $existing_tax_query = is_array( $existing_tax_query ) ? $existing_tax_query : array();
+ $existing_tax_query[] = array(
+ 'taxonomy' => 'product_visibility',
+ 'field' => 'term_taxonomy_id',
+ 'terms' => array( $exclude_term_id ),
+ 'operator' => 'NOT IN',
+ );
+ $q->set( 'tax_query', $existing_tax_query );
+ }
+ }
return;
}
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 f8d1bad9de1..0deec742768 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-query-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-query-test.php
@@ -1,4 +1,5 @@
<?php
+
declare( strict_types = 1 );
/**
@@ -137,4 +138,156 @@ class WC_Query_Test extends \WC_Unit_Test_Case {
$this->assertNotEquals( 'relevance', $result['orderby'], 'Exclusion-only search with custom prefix should not use relevance ordering' );
}
+
+ /**
+ * @testdox Sitewide search includes or excludes products according to their catalog visibility setting.
+ *
+ * @dataProvider visibility_search_provider
+ *
+ * @param string $visibility The catalog visibility setting to test.
+ * @param bool $should_be_found Whether the product is expected to appear in search results.
+ * @param string $expected_message The expected assertion message.
+ */
+ public function test_search_respects_product_visibility( string $visibility, bool $should_be_found, string $expected_message ) {
+ // Create a baseline product that should always appear in search.
+ $visible_product = WC_Helper_Product::create_simple_product();
+ $visible_product->set_name( 'Search Visible Product' );
+ $visible_product->set_catalog_visibility( 'visible' );
+ $visible_product->save();
+
+ // Create the product under test with the visibility provided by the data provider.
+ $test_product = WC_Helper_Product::create_simple_product();
+ $test_product->set_name( 'Search Tested Product' );
+ $test_product->set_catalog_visibility( $visibility );
+ $test_product->save();
+
+ // Save the previous main query and prepare for a new one.
+ global $wp_the_query, $wp_query;
+ $previous_wp_the_query = $wp_the_query;
+ $previous_wp_query = $wp_query;
+
+ // Set the query as the main query before running so pre_get_posts fires with WC_Query's handler.
+ $query = new WP_Query();
+ $wp_the_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $wp_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ $query->query( array( 's' => 'Search' ) );
+ $found_ids = wp_list_pluck( $query->posts, 'ID' );
+
+ $this->assertContains( $visible_product->get_id(), $found_ids, 'Visible product should always appear in search results' );
+
+ if ( $should_be_found ) {
+ $this->assertContains( $test_product->get_id(), $found_ids, $expected_message );
+ } else {
+ $this->assertNotContains( $test_product->get_id(), $found_ids, $expected_message );
+ }
+
+ // Cleanup.
+ $wp_the_query = $previous_wp_the_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $wp_query = $previous_wp_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $visible_product->delete( true );
+ $test_product->delete( true );
+ }
+
+ /**
+ * Data provider for visibility-based search tests.
+ *
+ * @return array
+ */
+ public function visibility_search_provider(): array {
+ return array(
+ 'catalog visibility (shop only)' => array( 'catalog', false, 'Product with catalog-only visibility should not appear in search results' ),
+ 'hidden visibility' => array( 'hidden', false, 'Product with hidden visibility should not appear in search results' ),
+ 'search visibility' => array( 'search', true, 'Product with search-only visibility should appear in search results' ),
+ );
+ }
+
+ /**
+ * @testdox Sitewide search excludes hidden products while continuing to return regular posts.
+ */
+ public function test_search_excludes_hidden_products_but_keeps_other_post_types() {
+ $hidden_product = WC_Helper_Product::create_simple_product();
+ $hidden_product->set_name( 'Search Hidden Companion Product' );
+ $hidden_product->set_catalog_visibility( 'hidden' );
+ $hidden_product->save();
+
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_title' => 'Search Regular Post',
+ 'post_content' => 'Body content referencing Search.',
+ )
+ );
+
+ global $wp_the_query, $wp_query;
+ $previous_wp_the_query = $wp_the_query;
+ $previous_wp_query = $wp_query;
+
+ $query = new WP_Query();
+ $wp_the_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $wp_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ $query->query( array( 's' => 'Search' ) );
+ $found_ids = wp_list_pluck( $query->posts, 'ID' );
+
+ $this->assertContains( $post_id, $found_ids, 'Regular posts should still appear in sitewide search results' );
+ $this->assertNotContains( $hidden_product->get_id(), $found_ids, 'Hidden products should be filtered out of sitewide search results' );
+
+ $wp_the_query = $previous_wp_the_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $wp_query = $previous_wp_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ wp_delete_post( $post_id, true );
+ $hidden_product->delete( true );
+ }
+
+ /**
+ * @testdox A tax_query set by another plugin or hook before WC_Query's pre_get_posts survives the visibility merge.
+ */
+ public function test_search_preserves_existing_tax_query() {
+ $existing_clause = array(
+ 'taxonomy' => 'category',
+ 'field' => 'slug',
+ 'terms' => array( 'uncategorized' ),
+ );
+
+ // Hook at priority 5 so it runs before WC_Query::pre_get_posts (default priority 10).
+ $hook = function ( $q ) use ( $existing_clause ) {
+ if ( $q->is_search() ) {
+ $q->set( 'tax_query', array( $existing_clause ) );
+ }
+ };
+ add_action( 'pre_get_posts', $hook, 5 );
+
+ global $wp_the_query, $wp_query;
+ $previous_wp_the_query = $wp_the_query;
+ $previous_wp_query = $wp_query;
+
+ $query = new WP_Query();
+ $wp_the_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $wp_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ $query->query( array( 's' => 'Search' ) );
+
+ $tax_query = $query->get( 'tax_query' );
+ $this->assertIsArray( $tax_query, 'Tax query should be an array after WC_Query merges its clause.' );
+ $this->assertContains( $existing_clause, $tax_query, 'Pre-existing tax_query clause should survive the merge.' );
+
+ $product_visibility_terms = wc_get_product_visibility_term_ids();
+ $exclude_term_id = (int) $product_visibility_terms['exclude-from-search'];
+ $visibility_clause = null;
+ foreach ( $tax_query as $clause ) {
+ if ( is_array( $clause ) && isset( $clause['taxonomy'] ) && 'product_visibility' === $clause['taxonomy'] ) {
+ $visibility_clause = $clause;
+ break;
+ }
+ }
+ $this->assertNotNull( $visibility_clause, 'WC_Query should append the product_visibility exclusion clause to the existing tax_query.' );
+ $this->assertSame( 'term_taxonomy_id', $visibility_clause['field'], 'Visibility clause should match by term_taxonomy_id.' );
+ $this->assertSame( array( $exclude_term_id ), $visibility_clause['terms'], 'Visibility clause should target the exclude-from-search term.' );
+ $this->assertSame( 'NOT IN', $visibility_clause['operator'], 'Visibility clause should use the NOT IN operator.' );
+
+ remove_action( 'pre_get_posts', $hook, 5 );
+ $wp_the_query = $previous_wp_the_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $wp_query = $previous_wp_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
}