Commit 0f58c06978e for woocommerce

commit 0f58c06978eb4f7cfd0089a579568350857ec9d4
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Sep 11 16:02:25 2026 +0300

    Preserve local Product Collection constraints in filter counts (#68507)

    * fix: Preserve local collection constraints in filter counts

    Frontend filter counts removed every clause for their own taxonomy,
    including saved restrictions on local Product Collections. Recursive
    cleanup also discarded false and empty values that affect membership.

    Mark shopper clauses so local OR counts remove only the shopper selection,
    retain local AND constraints, and preserve unrelated query leaf values.
    Inherited collections and standalone filters keep their existing behavior.

    Refs #45370

    * chore: Add changelog for local frontend filter counts

    Record the package change for the next WooCommerce release.

    Refs #45370

    * chore: Remove obsolete Product Collection PHPStan baseline

    The filter count repair removed the private recursive cleanup helper.
    Its missing-return-type baseline entry now matches no error, causing
    the full CI PHPStan check to fail.

    Remove that obsolete entry so the baseline reflects the repaired code.
    No new suppression or production behavior change is introduced.

    Refs #45370

    * test: Seed shopper params for every count-query row

    The count-query test only seeded the shopper's own filter param for
    inherited and standalone rows. For local and generic rows the key was
    never in the query, so the assertion that OR counts drop it could not
    fail.

    Seed the param, operator and other facet for every row, and pick the
    AND or OR assertion from the operator alone. Lower-casing the operator
    covers the `AND` row the same way the block does. Deleting the unset()
    in ProductFilterAttribute now fails every OR attribute row instead of
    one.

    Refs #45370

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: Treat a missing inherit flag as a local collection

    Product Collection sets `inherit` by hand in its defaults and in
    patterns; the block schema never adds it. When it was missing, the
    filter blocks and Utils::get_query_vars() disagreed about it.
    get_query_vars() treated the collection as local and built the query
    from the saved block, including its restrictions. The filter blocks
    treated it as not local and stripped clauses by taxonomy name, which
    removed those saved restrictions from the counts again.

    Check `inherit` with empty() in both filter blocks and in
    get_query_vars(), so all three read it the same way and a missing key
    no longer raises an undefined-index warning. New count-query rows
    without `inherit` cover category OR, attribute OR and attribute AND,
    and a get_query_vars() test checks which query each context shape
    uses.

    Refs #45370

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test: Clear ESLint warnings in the product filters frontend spec

    Touching this spec puts the whole file under the branch ESLint check,
    which surfaced two warnings that already existed on trunk.

    The react-hooks rule mistakes Playwright's fixture `use` for React's
    `use` hook, so disable it on that line with the same explanation used
    in utils/back-in-stock-notifications.ts. The playwright/no-skipped-test
    directive above test.skip() silenced nothing, since eslint.config.mjs
    turns that rule off, so remove it.

    Refs #45370

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-product-filter-local-frontend-counts b/plugins/woocommerce/changelog/fix-product-filter-local-frontend-counts
new file mode 100644
index 00000000000..16ba7c306a5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-filter-local-frontend-counts
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve saved local Product Collection constraints when calculating frontend filter counts.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index cae612f3c43..9c69a31fec6 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -50979,12 +50979,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/ProductCollection/Utils.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\Utils\:\:remove_empty_array_recursive\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Blocks/BlockTypes/ProductCollection/Utils.php
-
 		-
 			message: '#^Parameter \#1 \$block of function build_query_vars_from_query_block expects WP_Block, Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\WP_Block given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 03240cc519a..e27aca233c0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -522,11 +522,13 @@ class QueryBuilder {
 				// It is necessary explode the value because $attribute_value can be a string with multiple values (e.g. "red,blue").
 				$attribute_value = explode( ',', $attribute_value );

-				$acc[] = array(
-					'taxonomy' => str_replace( AttributeFilter::FILTER_QUERY_VAR_PREFIX, 'pa_', $attribute_name ),
-					'field'    => 'slug',
-					'terms'    => $attribute_value,
-					'operator' => 'and' === $attribute_query ? 'AND' : 'IN',
+				$taxonomy = str_replace( AttributeFilter::FILTER_QUERY_VAR_PREFIX, 'pa_', $attribute_name );
+				$acc[]    = array(
+					'taxonomy'        => $taxonomy,
+					'field'           => 'slug',
+					'terms'           => $attribute_value,
+					'operator'        => 'and' === $attribute_query ? 'AND' : 'IN',
+					'filter_taxonomy' => $taxonomy,
 				);

 				return $acc;
@@ -699,10 +701,11 @@ class QueryBuilder {
 			}

 			$tax_queries[] = array(
-				'taxonomy' => $taxonomy_slug,
-				'field'    => 'slug',
-				'terms'    => $term_slugs,
-				'operator' => 'IN',
+				'taxonomy'        => $taxonomy_slug,
+				'field'           => 'slug',
+				'terms'           => $term_slugs,
+				'operator'        => 'IN',
+				'filter_taxonomy' => $taxonomy_slug,
 			);
 		}

diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
index 8c23a68e684..33af697286d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
@@ -69,7 +69,7 @@ class Utils {
 	 * @return array Returns the constructed WP_Query arguments.
 	 */
 	public static function get_query_vars( $block, $page ) {
-		if ( ! empty( $block->context['query'] ) && ! $block->context['query']['inherit'] ) {
+		if ( ! empty( $block->context['query'] ) && empty( $block->context['query']['inherit'] ) ) {
 			return build_query_vars_from_query_block( $block, $page );
 		}

@@ -78,8 +78,8 @@ class Utils {
 	}

 	/**
-	 * Remove query array from tax or meta query by searching for arrays that
-	 * contain exact key => value pair.
+	 * Remove matching tax or meta query clauses and prune empty groups.
+	 * Preserve all values in the remaining clauses.
 	 *
 	 * @param array  $queries tax_query or meta_query.
 	 * @param string $key     Array key to search for.
@@ -93,16 +93,27 @@ class Utils {
 		}

 		foreach ( $queries as $query_key => $query ) {
+			if ( ! is_array( $query ) ) {
+				continue;
+			}
+
 			if ( isset( $query[ $key ] ) && $query[ $key ] === $value ) {
 				unset( $queries[ $query_key ] );
+				continue;
+			}
+
+			// Leaf values can contain arrays and falsy values that must remain unchanged.
+			if ( isset( $query['taxonomy'] ) || isset( $query['key'] ) || array_key_exists( 'terms', $query ) || array_key_exists( 'value', $query ) ) {
+				continue;
 			}

-			if ( isset( $query['relation'] ) || ! isset( $query[ $key ] ) ) {
-				$queries[ $query_key ] = self::remove_query_array( $query, $key, $value );
+			$queries[ $query_key ] = self::remove_query_array( $query, $key, $value );
+			if ( empty( $queries[ $query_key ] ) ) {
+				unset( $queries[ $query_key ] );
 			}
 		}

-		return self::remove_empty_array_recursive( $queries );
+		return 1 === count( $queries ) && isset( $queries['relation'] ) ? array() : $queries;
 	}

 	/**
@@ -192,19 +203,4 @@ class Utils {

 		return $context;
 	}
-
-	/**
-	 * Remove falsy item from array, recursively.
-	 *
-	 * @param array $array The input array to filter.
-	 */
-	private static function remove_empty_array_recursive( $array ) {
-		$array = array_filter( $array );
-		foreach ( $array as $key => $item ) {
-			if ( is_array( $item ) ) {
-				$array[ $key ] = self::remove_empty_array_recursive( $item );
-			}
-		}
-		return $array;
-	}
 }
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
index 24f68a915dc..7694a8aad28 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
@@ -277,7 +277,8 @@ final class ProductFilterAttribute extends AbstractBlock {
 	 * @param string   $query_type Query type, accept 'and' or 'or'.
 	 */
 	private function get_attribute_counts( $block, $slug, $query_type ) {
-		if ( ! isset( $block->context['filterParams'] ) ) {
+		$block_context = $block->context;
+		if ( ! isset( $block_context['filterParams'] ) ) {
 			return array();
 		}

@@ -294,9 +295,13 @@ final class ProductFilterAttribute extends AbstractBlock {
 			);
 		}

-		if ( ! empty( $query_vars['tax_query'] ) ) {
+		$is_local_collection = true === ( $block_context['query']['isProductCollectionBlock'] ?? false )
+			&& empty( $block_context['query']['inherit'] );
+
+		if ( ! empty( $query_vars['tax_query'] ) && ( ! $is_local_collection || 'and' !== strtolower( $query_type ) ) ) {
+			$removal_key = $is_local_collection ? 'filter_taxonomy' : 'taxonomy';
 			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
-			$query_vars['tax_query'] = ProductCollectionUtils::remove_query_array( $query_vars['tax_query'], 'taxonomy', $slug );
+			$query_vars['tax_query'] = ProductCollectionUtils::remove_query_array( $query_vars['tax_query'], $removal_key, $slug );
 		}

 		$container        = wc_get_container();
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
index 9db3982fdd0..46a28ba0ae5 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
@@ -367,7 +367,8 @@ final class ProductFilterTaxonomy extends AbstractBlock {
 	 * @return array Term counts with term_id as key and count as value.
 	 */
 	private function get_taxonomy_term_counts( $block, $taxonomy ) {
-		if ( ! isset( $block->context['filterParams'] ) ) {
+		$block_context = $block->context;
+		if ( ! isset( $block_context['filterParams'] ) ) {
 			return array();
 		}

@@ -396,10 +397,13 @@ final class ProductFilterTaxonomy extends AbstractBlock {
 			);
 		}

-		// Remove from tax_query if present.
+		$is_local_collection = true === ( $block_context['query']['isProductCollectionBlock'] ?? false )
+			&& empty( $block_context['query']['inherit'] );
+
 		if ( ! empty( $query_vars['tax_query'] ) ) {
+			$removal_key = $is_local_collection ? 'filter_taxonomy' : 'taxonomy';
 			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
-			$query_vars['tax_query'] = ProductCollectionUtils::remove_query_array( $query_vars['tax_query'], 'taxonomy', $taxonomy );
+			$query_vars['tax_query'] = ProductCollectionUtils::remove_query_array( $query_vars['tax_query'], $removal_key, $taxonomy );
 		}

 		$counts = $container->get( FilterDataProvider::class )->with( $container->get( QueryClauses::class ) )->get_taxonomy_counts( $query_vars, $taxonomy );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
index 9be2b47dd90..afdb30d3155 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
@@ -8,11 +8,182 @@ const test = base.extend< { templateCompiler: TemplateCompiler } >( {
 		const compiler = await requestUtils.createTemplateFromFile(
 			'archive-product_attribute-filter'
 		);
+		// eslint-disable-next-line react-hooks/rules-of-hooks -- Playwright's fixture `use`, not a React hook.
 		await use( compiler );
 	},
 } );

 test.describe( 'woocommerce/product-filters - Frontend', () => {
+	test( 'preserves saved local categories in frontend filter counts', async ( {
+		page,
+		requestUtils,
+	} ) => {
+		const parentCategory = await requestUtils.rest( {
+			method: 'POST',
+			path: '/wc/v3/products/categories',
+			data: { name: 'Collection parent' },
+		} );
+		const childCategory = await requestUtils.rest( {
+			method: 'POST',
+			path: '/wc/v3/products/categories',
+			data: { name: 'Collection child', parent: parentCategory.id },
+		} );
+		const outsideCategory = await requestUtils.rest( {
+			method: 'POST',
+			path: '/wc/v3/products/categories',
+			data: { name: 'Outside collection' },
+		} );
+
+		for ( const fixture of [
+			{
+				name: 'Direct parent product',
+				category: parentCategory.id,
+				price: '17',
+				rating: 2,
+			},
+			{
+				name: 'Child-only product',
+				category: childCategory.id,
+				price: '93',
+				rating: 5,
+			},
+			{
+				name: 'Outside product',
+				category: outsideCategory.id,
+				price: '93',
+				rating: 4,
+			},
+		] ) {
+			const product = await requestUtils.rest( {
+				method: 'POST',
+				path: '/wc/v3/products',
+				data: {
+					name: fixture.name,
+					type: 'simple',
+					status: 'publish',
+					regular_price: fixture.price,
+					categories: [ { id: fixture.category } ],
+				},
+			} );
+			await requestUtils.rest( {
+				method: 'POST',
+				path: '/wc/v3/products/reviews',
+				data: {
+					product_id: product.id,
+					review: `Review for ${ fixture.name }`,
+					reviewer: 'Test shopper',
+					reviewer_email: 'shopper@example.com',
+					rating: fixture.rating,
+					status: 'approved',
+				},
+			} );
+		}
+
+		const collectionAttributes = JSON.stringify( {
+			queryId: 1,
+			query: {
+				perPage: 9,
+				pages: 0,
+				offset: 0,
+				postType: 'product',
+				order: 'asc',
+				orderBy: 'title',
+				inherit: false,
+				filterable: true,
+				isProductCollectionBlock: true,
+				taxQuery: { product_cat: [ parentCategory.id ] },
+			},
+			displayLayout: { type: 'flex', columns: 3 },
+		} );
+		const post = await requestUtils.rest( {
+			method: 'POST',
+			path: '/wp/v2/posts',
+			data: {
+				title: 'Local collection filter counts',
+				status: 'publish',
+				content: `<!-- wp:woocommerce/product-collection ${ collectionAttributes } -->
+<div class="wp-block-woocommerce-product-collection">
+	<!-- wp:woocommerce/product-filters {"overlayMode":"off"} -->
+	<div class="wp-block-woocommerce-product-filters wc-block-product-filters">
+		<!-- wp:woocommerce/product-filter-taxonomy {"taxonomy":"product_cat","showCounts":true,"hideEmpty":true} -->
+		<div class="wp-block-woocommerce-product-filter-taxonomy">
+			<!-- wp:woocommerce/product-filter-checkbox-list -->
+			<div class="wp-block-woocommerce-product-filter-checkbox-list wc-block-product-filter-checkbox-list"></div>
+			<!-- /wp:woocommerce/product-filter-checkbox-list -->
+		</div>
+		<!-- /wp:woocommerce/product-filter-taxonomy -->
+		<!-- wp:woocommerce/product-filter-rating {"showCounts":true} -->
+		<div class="wp-block-woocommerce-product-filter-rating">
+			<!-- wp:woocommerce/product-filter-checkbox-list -->
+			<div class="wp-block-woocommerce-product-filter-checkbox-list wc-block-product-filter-checkbox-list"></div>
+			<!-- /wp:woocommerce/product-filter-checkbox-list -->
+		</div>
+		<!-- /wp:woocommerce/product-filter-rating -->
+	</div>
+	<!-- /wp:woocommerce/product-filters -->
+	<!-- wp:woocommerce/product-template -->
+		<!-- wp:post-title {"level":3,"isLink":true,"__woocommerceNamespace":"woocommerce/product-collection/product-title"} /-->
+	<!-- /wp:woocommerce/product-template -->
+</div>
+<!-- /wp:woocommerce/product-collection -->`,
+			},
+		} );
+
+		await page.goto( post.link );
+
+		const collection = page.locator(
+			'.wp-block-woocommerce-product-collection'
+		);
+		const productTitles = collection.locator( '.wp-block-post-title' );
+		const categoryFilter = collection.locator(
+			'.wp-block-woocommerce-product-filter-taxonomy'
+		);
+		const parentCheckbox = categoryFilter.getByRole( 'checkbox', {
+			name: /Collection parent/,
+		} );
+		const ratingFilter = collection.locator(
+			'.wp-block-woocommerce-product-filter-rating'
+		);
+
+		await expect( productTitles ).toHaveText( [ 'Direct parent product' ] );
+		await expect( parentCheckbox ).toBeVisible();
+		await expect( categoryFilter.getByRole( 'checkbox' ) ).toHaveCount( 1 );
+		await expect(
+			categoryFilter.getByText( 'Collection child', { exact: true } )
+		).toHaveCount( 0 );
+		await expect(
+			categoryFilter.getByText( 'Outside collection', { exact: true } )
+		).toHaveCount( 0 );
+		await expect(
+			categoryFilter.locator(
+				'.wc-block-product-filter-checkbox-list__count'
+			)
+		).toHaveText( '(1)' );
+		await expect(
+			ratingFilter.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
+		).toBeVisible();
+		await expect( ratingFilter.getByRole( 'checkbox' ) ).toHaveCount( 1 );
+
+		await parentCheckbox.check();
+		await page.waitForURL(
+			( url ) =>
+				url.searchParams.get( 'categories' ) === parentCategory.slug
+		);
+
+		await expect( parentCheckbox ).toBeChecked();
+		await expect( productTitles ).toHaveText( [ 'Direct parent product' ] );
+		await expect( categoryFilter.getByRole( 'checkbox' ) ).toHaveCount( 1 );
+		await expect(
+			categoryFilter.locator(
+				'.wc-block-product-filter-checkbox-list__count'
+			)
+		).toHaveText( '(1)' );
+		await expect(
+			ratingFilter.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
+		).toBeVisible();
+		await expect( ratingFilter.getByRole( 'checkbox' ) ).toHaveCount( 1 );
+	} );
+
 	test.describe( 'Overlay', () => {
 		test.beforeEach( async ( { templateCompiler, page } ) => {
 			await templateCompiler.compile( {
@@ -90,7 +261,6 @@ test.describe( 'woocommerce/product-filters - Frontend', () => {
 		} );

 		// Skipping these tests until we can move this block to @wordpress/interactivity.
-		// eslint-disable-next-line playwright/no-skipped-test
 		test.skip( 'filter is working inside overlay', async ( { page } ) => {
 			await page.setViewportSize( { width: 400, height: 600 } );
 			await page.goto( '/shop' );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 0728e2bd3cc..397c25dac47 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -395,7 +395,7 @@ class QueryBuilder extends \WP_UnitTestCase {
 	}

 	/**
-	 * Test merging filter by stock status queries.
+	 * @testdox Test merging filter by stock status queries.
 	 */
 	public function test_merging_filter_by_attribute_queries() {
 		// Mock the attribute data.
@@ -439,20 +439,22 @@ class QueryBuilder extends \WP_UnitTestCase {

 		$this->assertContainsEquals(
 			array(
-				'taxonomy' => 'pa_color',
-				'field'    => 'slug',
-				'terms'    => array( 'blue' ),
-				'operator' => 'IN',
+				'filter_taxonomy' => 'pa_color',
+				'taxonomy'        => 'pa_color',
+				'field'           => 'slug',
+				'terms'           => array( 'blue' ),
+				'operator'        => 'IN',
 			),
 			$attribute_queries
 		);

 		$this->assertContainsEquals(
 			array(
-				'taxonomy' => 'pa_size',
-				'field'    => 'slug',
-				'terms'    => array( 'xl', 'xxl' ),
-				'operator' => 'AND',
+				'filter_taxonomy' => 'pa_size',
+				'taxonomy'        => 'pa_size',
+				'field'           => 'slug',
+				'terms'           => array( 'xl', 'xxl' ),
+				'operator'        => 'AND',
 			),
 			$attribute_queries
 		);
@@ -1125,7 +1127,7 @@ class QueryBuilder extends \WP_UnitTestCase {
 	}

 	/**
-	 * Test merging filter queries by Category Slug (e.g. ?categories=accessories).
+	 * @testdox Test merging filter queries by Category Slug (e.g. ?categories=accessories).
 	 */
 	public function test_merging_filter_by_category_slug() {
 		// Set the URL query variables.
@@ -1138,10 +1140,11 @@ class QueryBuilder extends \WP_UnitTestCase {
 		// Assertions.
 		$this->assertContainsEquals(
 			array(
-				'taxonomy' => 'product_cat',
-				'field'    => 'slug',
-				'terms'    => array( 'accessories' ),
-				'operator' => 'IN',
+				'filter_taxonomy' => 'product_cat',
+				'taxonomy'        => 'product_cat',
+				'field'           => 'slug',
+				'terms'           => array( 'accessories' ),
+				'operator'        => 'IN',
 			),
 			$filter_clauses,
 			'Should contain correct product_cat tax query using slug.'
@@ -1152,7 +1155,7 @@ class QueryBuilder extends \WP_UnitTestCase {
 	}

 	/**
-	 * Test merging filter queries specifically for Tags.
+	 * @testdox Test merging filter queries specifically for Tags.
 	 * Scenario: ?tags=tag-new (Slug)
 	 */
 	public function test_merging_filter_by_tags() {
@@ -1166,10 +1169,11 @@ class QueryBuilder extends \WP_UnitTestCase {
 		// Assertions.
 		$this->assertContainsEquals(
 			array(
-				'taxonomy' => 'product_tag',
-				'field'    => 'slug',
-				'terms'    => array( 'tag-new' ),
-				'operator' => 'IN',
+				'filter_taxonomy' => 'product_tag',
+				'taxonomy'        => 'product_tag',
+				'field'           => 'slug',
+				'terms'           => array( 'tag-new' ),
+				'operator'        => 'IN',
 			),
 			$filter_clauses,
 			'Should contain correct product_tag tax query with IN operator.'
@@ -1180,7 +1184,7 @@ class QueryBuilder extends \WP_UnitTestCase {
 	}

 	/**
-	 * Test merging filter queries for Categories, Tags, and Brands simultaneously.
+	 * @testdox Test merging filter queries for Categories, Tags, and Brands simultaneously.
 	 * Scenario: ?categories=accessories&tags=tag-new&brands=nike
 	 */
 	public function test_merging_filter_by_all_taxonomies_together() {
@@ -1197,10 +1201,11 @@ class QueryBuilder extends \WP_UnitTestCase {
 		// Verify Category.
 		$this->assertContainsEquals(
 			array(
-				'taxonomy' => 'product_cat',
-				'field'    => 'slug',
-				'terms'    => array( 'accessories' ),
-				'operator' => 'IN',
+				'filter_taxonomy' => 'product_cat',
+				'taxonomy'        => 'product_cat',
+				'field'           => 'slug',
+				'terms'           => array( 'accessories' ),
+				'operator'        => 'IN',
 			),
 			$filter_clauses,
 			'Should contain correct product_cat tax query.'
@@ -1209,10 +1214,11 @@ class QueryBuilder extends \WP_UnitTestCase {
 		// Verify Tag.
 		$this->assertContainsEquals(
 			array(
-				'taxonomy' => 'product_tag',
-				'field'    => 'slug',
-				'terms'    => array( 'tag-new' ),
-				'operator' => 'IN',
+				'filter_taxonomy' => 'product_tag',
+				'taxonomy'        => 'product_tag',
+				'field'           => 'slug',
+				'terms'           => array( 'tag-new' ),
+				'operator'        => 'IN',
 			),
 			$filter_clauses,
 			'Should contain correct product_tag tax query.'
@@ -1221,10 +1227,11 @@ class QueryBuilder extends \WP_UnitTestCase {
 		// Verify Brand.
 		$this->assertContainsEquals(
 			array(
-				'taxonomy' => 'product_brand',
-				'field'    => 'slug',
-				'terms'    => array( 'nike' ),
-				'operator' => 'IN',
+				'filter_taxonomy' => 'product_brand',
+				'taxonomy'        => 'product_brand',
+				'field'           => 'slug',
+				'terms'           => array( 'nike' ),
+				'operator'        => 'IN',
 			),
 			$filter_clauses,
 			'Should contain correct product_brand tax query.'
@@ -1237,7 +1244,7 @@ class QueryBuilder extends \WP_UnitTestCase {
 	}

 	/**
-	 * Test that the strictly string-based filter logic works and SAFELY ignores arrays.
+	 * @testdox Test that the strictly string-based filter logic works and SAFELY ignores arrays.
 	 * Matches logic: if ( ! is_string($param_value) ) continue;
 	 */
 	public function test_filter_strict_string_handling() {
@@ -1253,10 +1260,11 @@ class QueryBuilder extends \WP_UnitTestCase {
 		// Assertion: The array input should have been ignored.
 		$this->assertNotContainsEquals(
 			array(
-				'taxonomy' => 'product_cat',
-				'field'    => 'slug',
-				'terms'    => array( 'hats' ),
-				'operator' => 'IN',
+				'filter_taxonomy' => 'product_cat',
+				'taxonomy'        => 'product_cat',
+				'field'           => 'slug',
+				'terms'           => array( 'hats' ),
+				'operator'        => 'IN',
 			),
 			$filter_clauses,
 			'Should not contain product_cat tax query because array input should be ignored.'
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/UtilsTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/UtilsTest.php
new file mode 100644
index 00000000000..79cf80976f7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/UtilsTest.php
@@ -0,0 +1,171 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection;
+
+use Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\Utils as ProductCollectionUtils;
+use WC_Unit_Test_Case;
+use WP_Block;
+
+/**
+ * Tests for Product Collection query utilities.
+ */
+class UtilsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should remove nested matching clauses without changing saved query values.
+	 */
+	public function test_removes_nested_filters_without_changing_saved_constraints(): void {
+		$saved_taxonomy = array(
+			'taxonomy'         => 'product_cat',
+			'terms'            => array( 12, 0 ),
+			'include_children' => false,
+		);
+		$saved_meta     = array(
+			'key'     => '_price',
+			'value'   => 0,
+			'compare' => '>=',
+		);
+		$queries        = array(
+			'relation' => 'AND',
+			'saved'    => $saved_taxonomy,
+			'price'    => $saved_meta,
+			'shopper'  => array(
+				'relation' => 'OR',
+				'other'    => array(
+					'taxonomy' => 'product_tag',
+					'terms'    => array( 23 ),
+				),
+				'rating'   => array(
+					'taxonomy'      => 'product_visibility',
+					'terms'         => array( 34 ),
+					'rating_filter' => true,
+				),
+			),
+		);
+
+		$result = ProductCollectionUtils::remove_query_array( $queries, 'rating_filter', true );
+
+		$this->assertArrayNotHasKey( 'rating', $result['shopper'] );
+		$this->assertSame( $saved_taxonomy, $result['saved'], 'Saved taxonomy values, including false and zero, must remain exact.' );
+		$this->assertSame( $saved_meta, $result['price'], 'Zero-valued price constraints must not change.' );
+		$this->assertSame( 'OR', $result['shopper']['relation'] );
+		$this->assertSame( array( 23 ), $result['shopper']['other']['terms'] );
+		$this->assertSame( 'AND', $result['relation'] );
+	}
+
+	/**
+	 * @testdox Should prune groups after removing their last matching clause.
+	 */
+	public function test_prunes_empty_query_groups(): void {
+		$queries = array(
+			'relation' => 'AND',
+			array(
+				'relation' => 'OR',
+				array(
+					'taxonomy' => 'product_cat',
+					'terms'    => array( 12 ),
+				),
+			),
+		);
+
+		$result = ProductCollectionUtils::remove_query_array( $queries, 'taxonomy', 'product_cat' );
+
+		$this->assertSame( array(), $result );
+	}
+
+	/**
+	 * @testdox Should preserve empty term lists and falsy values in untouched clauses.
+	 */
+	public function test_preserves_untouched_leaf_values(): void {
+		$queries = array(
+			array(
+				'taxonomy'         => 'product_cat',
+				'terms'            => array(),
+				'include_children' => false,
+			),
+			array(
+				'key'   => '_custom_value',
+				'value' => array( false, 0, '0', '', null ),
+			),
+		);
+
+		$result = ProductCollectionUtils::remove_query_array( $queries, 'rating_filter', true );
+
+		$this->assertSame( $queries, $result );
+	}
+
+	/**
+	 * @testdox Should preserve empty term lists in clauses without a taxonomy.
+	 */
+	public function test_preserves_term_taxonomy_id_clauses_without_taxonomy(): void {
+		$queries = array(
+			array(
+				'field' => 'term_taxonomy_id',
+				'terms' => array(),
+			),
+		);
+
+		$result = ProductCollectionUtils::remove_query_array( $queries, 'rating_filter', true );
+
+		$this->assertSame( $queries, $result );
+	}
+
+	/**
+	 * @testdox Should match filter values strictly, including false.
+	 */
+	public function test_matches_filter_values_strictly(): void {
+		$queries = array(
+			array(
+				'rating_filter' => true,
+				'taxonomy'      => 'product_visibility',
+			),
+			array(
+				'rating_filter' => false,
+				'taxonomy'      => 'product_visibility',
+			),
+			array(
+				'rating_filter' => 0,
+				'taxonomy'      => 'product_visibility',
+			),
+		);
+
+		$result = ProductCollectionUtils::remove_query_array( $queries, 'rating_filter', false );
+
+		$this->assertArrayNotHasKey( 1, $result );
+		$this->assertTrue( $result[0]['rating_filter'] );
+		$this->assertArrayHasKey( 'rating_filter', $result[2] );
+		$this->assertSame( 0, $result[2]['rating_filter'] );
+	}
+
+	/**
+	 * @testdox Should build query vars from the block unless the collection inherits the global query.
+	 * @testWith [{"postType": "product", "inherit": false}, "block"]
+	 *           [{"postType": "product"}, "block"]
+	 *           [{"postType": "product", "inherit": true}, "global"]
+	 *           [null, "global"]
+	 *
+	 * @param array|null $query_context   Query block context, or null when the block has none.
+	 * @param string     $expected_source Which query the vars must come from.
+	 */
+	public function test_get_query_vars_uses_global_query_only_when_inherited( ?array $query_context, string $expected_source ): void {
+		global $wp_query;
+		$wp_query->query_vars = array( 'source' => 'global' );
+		add_filter(
+			'query_loop_block_query_vars',
+			static function () {
+				return array( 'source' => 'block' );
+			},
+			PHP_INT_MAX
+		);
+
+		$block = new WP_Block(
+			array( 'blockName' => 'core/post-template' ),
+			null === $query_context ? array() : array( 'query' => $query_context )
+		);
+
+		$query_vars = ProductCollectionUtils::get_query_vars( $block, 1 );
+
+		$this->assertSame( $expected_source, $query_vars['source'] ?? null );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterCollectionCountsTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterCollectionCountsTest.php
new file mode 100644
index 00000000000..59ed0c362bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterCollectionCountsTest.php
@@ -0,0 +1,196 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\QueryBuilder;
+use WC_Unit_Test_Case;
+use WP_Block;
+
+/**
+ * Tests the query passed from Product Filter blocks to their count provider.
+ */
+class ProductFilterCollectionCountsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Restore the registered attribute taxonomy, including when fixture creation fails.
+	 */
+	public function tearDown(): void {
+		if ( taxonomy_exists( 'pa_material' ) ) {
+			unregister_taxonomy( 'pa_material' );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should retain saved taxonomy constraints only for explicitly local Product Collections.
+	 * @testWith ["product_cat", "local", "or", true, 1]
+	 *           ["product_cat", "local", "or", false, 1]
+	 *           ["product_cat", "local-without-inherit", "or", true, 1]
+	 *           ["product_cat", "inherited", "or", true, 0]
+	 *           ["product_cat", "standalone", "or", true, 0]
+	 *           ["product_cat", "generic", "or", true, 0]
+	 *           ["product_cat", "generic-without-marker", "or", true, 0]
+	 *           ["pa_material", "local", "or", true, 1]
+	 *           ["pa_material", "local", "or", false, 1]
+	 *           ["pa_material", "local", "and", true, 2]
+	 *           ["pa_material", "local", "AND", true, 2]
+	 *           ["pa_material", "local-without-inherit", "or", true, 1]
+	 *           ["pa_material", "local-without-inherit", "and", true, 2]
+	 *           ["pa_material", "inherited", "or", true, 0]
+	 *           ["pa_material", "standalone", "and", true, 0]
+	 *           ["pa_material", "generic", "or", true, 0]
+	 *           ["pa_material", "generic-without-marker", "or", true, 0]
+	 *
+	 * @param string $taxonomy Taxonomy whose counts are requested.
+	 * @param string $context_type Collection context to render in.
+	 * @param string $query_type Shopper attribute operator.
+	 * @param bool   $has_selection Whether the shopper selected a term.
+	 * @param int    $expected_clause_count Number of own-taxonomy constraints retained for counts.
+	 */
+	public function test_count_query_preserves_local_collection_boundary( string $taxonomy, string $context_type, string $query_type, bool $has_selection, int $expected_clause_count ): void {
+		$is_attribute = 'pa_material' === $taxonomy;
+		$is_local     = in_array( $context_type, array( 'local', 'local-without-inherit' ), true );
+		$attributes   = array(
+			'taxonomy'  => $taxonomy,
+			'sortOrder' => 'name-asc',
+		);
+
+		if ( $is_attribute ) {
+			$attribute_id = wc_create_attribute(
+				array(
+					'name' => 'Material',
+					'slug' => 'material',
+				)
+			);
+			$this->assertIsInt( $attribute_id );
+			register_taxonomy( 'pa_material', array( 'product' ) );
+			$attributes = array(
+				'attributeId' => $attribute_id,
+				'queryType'   => $query_type,
+				'sortOrder'   => 'name-asc',
+				'hideEmpty'   => false,
+			);
+		}
+
+		$own_param = $is_attribute ? 'filter_material' : 'categories';
+		set_query_var( $own_param, $has_selection ? 'shopper-selection' : '' );
+		set_query_var( 'query_type_material', $query_type );
+		set_query_var( 'tags', 'other-facet' );
+
+		$query_block   = new WP_Block(
+			array( 'blockName' => 'core/post-template' ),
+			array(
+				'query' => array(
+					'postType' => 'product',
+					'taxQuery' => array( $taxonomy => array( 101, 102 ) ),
+				),
+			)
+		);
+		$saved_query   = build_query_vars_from_query_block( $query_block, 1 );
+		$saved_clauses = self::get_taxonomy_clauses( $saved_query['tax_query'], $taxonomy );
+		$this->assertCount( 1, $saved_clauses, 'WordPress must build the saved taxonomy constraint.' );
+		$saved_clause = $saved_clauses[0];
+		$this->assertSame( array( 101, 102 ), $saved_clause['terms'] );
+
+		$builder    = new QueryBuilder();
+		$query_vars = $builder->get_final_frontend_query( array( 'name' => '' ), $saved_query );
+
+		$query_vars[ $own_param ]          = $has_selection ? 'shopper-selection' : '';
+		$query_vars['query_type_material'] = $query_type;
+		$query_vars['tags']                = 'other-facet';
+
+		$initial_clauses = self::get_taxonomy_clauses( $query_vars['tax_query'], $taxonomy );
+		$this->assertCount( $has_selection ? 2 : 1, $initial_clauses, 'Fixture must combine distinct saved and shopper constraints.' );
+		$this->assertContains( $saved_clause, $initial_clauses );
+
+		$query_context = array(
+			'postType'                 => 'product',
+			'inherit'                  => 'inherited' === $context_type,
+			'isProductCollectionBlock' => 'generic' !== $context_type,
+		);
+		if ( 'generic-without-marker' === $context_type ) {
+			unset( $query_context['isProductCollectionBlock'] );
+		}
+		if ( 'local-without-inherit' === $context_type ) {
+			unset( $query_context['inherit'] );
+		}
+		$context = array( 'filterParams' => array() );
+		if ( 'standalone' !== $context_type ) {
+			$context['query'] = $query_context;
+		}
+
+		global $wp_query;
+		$wp_query->query_vars = $query_vars;
+		add_filter(
+			'query_loop_block_query_vars',
+			static function () use ( $query_vars ) {
+				return $query_vars;
+			},
+			PHP_INT_MAX
+		);
+
+		$captured_query = null;
+		add_filter(
+			'woocommerce_pre_product_filter_data',
+			static function ( $counts, $filter_type, $count_query ) use ( &$captured_query, $is_attribute ) {
+				if ( ( $is_attribute ? 'attribute' : 'taxonomy' ) === $filter_type ) {
+					$captured_query = $count_query;
+					return array();
+				}
+				return $counts;
+			},
+			10,
+			3
+		);
+
+		$block = new WP_Block(
+			array(
+				'blockName'    => $is_attribute ? 'woocommerce/product-filter-attribute' : 'woocommerce/product-filter-taxonomy',
+				'attrs'        => $attributes,
+				'innerBlocks'  => array(),
+				'innerHTML'    => '',
+				'innerContent' => array(),
+			),
+			$context
+		);
+		$block->render();
+
+		$this->assertIsArray( $captured_query, 'Rendering must reach the actual count provider.' );
+		$own_clauses = self::get_taxonomy_clauses( $captured_query['tax_query'], $taxonomy );
+		$this->assertCount( $expected_clause_count, $own_clauses, 'Local counts retain saved constraints, plus shopper AND selections.' );
+		if ( $is_local ) {
+			$this->assertContains( $saved_clause, $own_clauses, 'The retained clause must be the saved boundary, not the shopper selection.' );
+		}
+		$other_clauses = self::get_taxonomy_clauses( $captured_query['tax_query'], 'product_tag' );
+		$this->assertCount( 1, $other_clauses, 'The other facet selection must remain applied.' );
+		$this->assertSame( array( 'other-facet' ), $other_clauses[0]['terms'] );
+		if ( $is_attribute && 'and' === strtolower( $query_type ) ) {
+			$this->assertSame( 'shopper-selection', $captured_query[ $own_param ], 'AND selections must still constrain counts through QueryClauses.' );
+		} else {
+			$this->assertArrayNotHasKey( $own_param, $captured_query, 'OR counts must exclude the current shopper selection.' );
+		}
+	}
+
+	/**
+	 * Collect the leaf clauses for one taxonomy without depending on group depth.
+	 *
+	 * @param array  $queries Taxonomy query groups.
+	 * @param string $taxonomy Taxonomy to collect.
+	 * @return array
+	 */
+	private static function get_taxonomy_clauses( array $queries, string $taxonomy ): array {
+		$clauses = array();
+		foreach ( $queries as $query ) {
+			if ( ! is_array( $query ) ) {
+				continue;
+			}
+			if ( ( $query['taxonomy'] ?? null ) === $taxonomy ) {
+				$clauses[] = $query;
+			} else {
+				$clauses = array_merge( $clauses, self::get_taxonomy_clauses( $query, $taxonomy ) );
+			}
+		}
+		return $clauses;
+	}
+}