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
+	}
 }