Commit ed0954f6593 for woocommerce

commit ed0954f65934daa15052c7a58984c4de47544270
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 15 23:59:06 2026 +0300

    [tests] Reduce legacy Filter by Attribute E2E tests from 11 to 3 (#68609)

    * test(blocks): Reduce legacy Filter by Attribute E2E tests from 11 to 3

    The legacy Filter by Attribute block ran eleven browser titles across
    two specs. Three rendered attribute counts beside the All Products
    block. The other eight walked the editor controls and the frontend
    filter, each from a fresh setup.

    Add a Store API PHPUnit matrix for the OR attribute counts, Jest
    tests for the block's hand-off to the count hook and its count
    labels, a Jest test for the collection-data request, and a Jest
    suite for the editor controls. Delete the count spec and fold the
    other eight titles into three: the editor controls, the classic
    template filter, and the Product Collection filter, which now also
    proves that a deferred selection changes nothing before Apply. The
    Product Collection template takes the live Size attribute ID instead
    of a hard-coded one.

    Consolidates the mega-branch slices:
    - Slice 014: test(blocks): Move Attribute Filter counts below E2E
    - Slice 056: test(blocks): Move Attribute Filter behavior below E2E
    - test(blocks): preserve attribute count assertions
    - refactor(e2e): simplify migrated Blocks test contracts (this spec
      only)
    - test(e2e): prove filters defer until Apply (this spec only)

    Refs TESTOPS-234
    Refs #68046

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

    * test(blocks): Restore trunk formatting of the filters template

    A Prettier pass over the Product Collection filters template changed
    its quotes and attribute wrapping and dropped the file's final
    newline, which wp-prettier's Handlebars printer always strips. Only
    the attribute ID substitution was a real change, so the rest was diff
    noise, and the missing newline breaks .editorconfig.

    Restore the template from trunk and keep only the attributeId
    substitution.

    Refs TESTOPS-234

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

    * test(blocks): Use the shared attribute ID helper in the filter spec

    The attribute filter spec parsed `wc product_attribute list` output
    with its own helper and a regex. The shared getProductAttributeIds()
    helper from #68579 runs the same command, extracts the JSON without
    a regex, and validates the pa_size id the same way.

    Refs TESTOPS-234

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

    * test(blocks): Reuse the attribute ID helper in the Product Filters spec

    The Product Filters attribute spec kept its own copy of the attribute
    ID lookup, the same WP-CLI call and validation that the shared
    getProductAttributeIds() helper from #68579 already provides. Call the
    helper instead, and drop the local copy and its wpCLI import.

    Refs TESTOPS-234

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

    ---------

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

diff --git a/plugins/woocommerce/changelog/testops-234-legacy-attribute-filter b/plugins/woocommerce/changelog/testops-234-legacy-attribute-filter
new file mode 100644
index 00000000000..7afc4a0d34c
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-legacy-attribute-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce legacy Filter by Attribute E2E tests from 11 to 3; PHPUnit owns the OR attribute counts and Jest owns the count hand-off, the collection-data request, and the editor controls.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/collections/test/use-collection-data.tsx b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/collections/test/use-collection-data.tsx
new file mode 100644
index 00000000000..d59236d9fac
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/collections/test/use-collection-data.tsx
@@ -0,0 +1,112 @@
+/**
+ * External dependencies
+ */
+import { act, renderHook } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import {
+	useQueryStateByContext,
+	useQueryStateByKey,
+} from '../../use-query-state';
+import { useQueryStateContext } from '../../../providers/query-state-context';
+import { useCollection } from '../use-collection';
+import { useCollectionData } from '../use-collection-data';
+
+jest.mock( '../../use-query-state' );
+jest.mock( '../../../providers/query-state-context' );
+jest.mock( '../use-collection' );
+
+describe( 'useCollectionData', () => {
+	afterEach( () => {
+		jest.useRealTimers();
+		jest.clearAllMocks();
+	} );
+
+	test( 'passes active attribute and price filters to the collection-data request', () => {
+		jest.useFakeTimers();
+
+		const queryAttribute = {
+			taxonomy: 'pa_size',
+			queryType: 'or',
+		};
+		let registeredAttributeCounts: ( typeof queryAttribute )[] = [];
+		const queryState = {
+			attributes: [
+				{
+					attribute: 'pa_size',
+					operator: 'in',
+					slug: [ 'small' ],
+				},
+			],
+			min_price: '1500',
+			max_price: '4000',
+		};
+		let collectionDataQueryState: Record< string, unknown > = {};
+		const setCalculateAttributeCounts = jest.fn();
+		const setCollectionDataQueryState = jest.fn();
+		const setOtherQueryState = jest.fn();
+
+		( useQueryStateContext as jest.Mock ).mockReturnValue( 'page' );
+		( useQueryStateByContext as jest.Mock ).mockImplementation( () => [
+			collectionDataQueryState,
+			setCollectionDataQueryState,
+		] );
+		( useQueryStateByKey as jest.Mock ).mockImplementation(
+			( queryKey, defaultValue ) => [
+				queryKey === 'calculate_attribute_counts'
+					? registeredAttributeCounts
+					: defaultValue,
+				queryKey === 'calculate_attribute_counts'
+					? setCalculateAttributeCounts
+					: setOtherQueryState,
+			]
+		);
+		( useCollection as jest.Mock ).mockReturnValue( {
+			results: { attribute_counts: [] },
+			isLoading: false,
+		} );
+
+		const { rerender } = renderHook( () =>
+			useCollectionData( {
+				queryAttribute,
+				queryState,
+				isEditor: false,
+			} )
+		);
+
+		expect( setCalculateAttributeCounts ).toHaveBeenCalledWith( [
+			queryAttribute,
+		] );
+
+		registeredAttributeCounts = [ queryAttribute ];
+		collectionDataQueryState = {
+			calculate_attribute_counts: registeredAttributeCounts,
+		};
+		rerender();
+
+		act( () => {
+			jest.advanceTimersByTime( 200 );
+		} );
+
+		expect( useCollection ).toHaveBeenLastCalledWith( {
+			namespace: '/wc/store/v1',
+			resourceName: 'products/collection-data',
+			query: {
+				...queryState,
+				page: undefined,
+				per_page: undefined,
+				orderby: undefined,
+				order: undefined,
+				calculate_attribute_counts: [
+					{
+						taxonomy: 'pa_size',
+						query_type: 'or',
+					},
+				],
+			},
+			shouldSelect: true,
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/attribute-filter/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/attribute-filter/test/block.tsx
index e5c5e133dc9..d3a26338b1b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/attribute-filter/test/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/attribute-filter/test/block.tsx
@@ -5,7 +5,7 @@
 /**
  * External dependencies
  */
-import { act, render, screen } from '@testing-library/react';
+import { act, render, screen, within } from '@testing-library/react';
 import * as hooks from '@woocommerce/base-context/hooks';
 import userEvent from '@testing-library/user-event';

@@ -85,9 +85,12 @@ const stubCollectionData = () => ( {

 interface SetupParams {
 	initialUrl: string;
+	productAttributeTerms?: ReturnType< typeof stubProductsAttributesTerms >;
+	collectionData?: ReturnType< typeof stubCollectionData >;
+	queryState?: Record< string, unknown >;
 }

-const setup = ( params: SetupParams ) => {
+const renderBlock = ( params: SetupParams ) => {
 	const setupParams: SetupParams = {
 		initialUrl: params.initialUrl || 'http://woo.local/',
 	};
@@ -108,15 +111,24 @@ const setup = ( params: SetupParams ) => {
 		isPreview: false,
 	};
 	jest.spyOn( hooks, 'useCollection' ).mockReturnValue( {
-		results: stubProductsAttributesTerms(),
+		results: params.productAttributeTerms || stubProductsAttributesTerms(),
 		isLoading: false,
 	} );

 	jest.spyOn( hooks, 'useCollectionData' ).mockReturnValue( {
-		data: stubCollectionData(),
+		data: params.collectionData || stubCollectionData(),
 		isLoading: false,
 	} );
-	const utils = render( <AttributeFilterBlock attributes={ attributes } /> );
+	jest.spyOn( hooks, 'useQueryStateByContext' ).mockReturnValue( [
+		params.queryState || {},
+		jest.fn(),
+	] );
+
+	return render( <AttributeFilterBlock attributes={ attributes } /> );
+};
+
+const setup = ( params: SetupParams ) => {
+	const utils = renderBlock( params );
 	const applyButton = screen.getByRole( 'button', { name: /apply/i } );
 	const smallAttributeCheckbox = screen.getByRole( 'checkbox', {
 		name: /small/i,
@@ -157,6 +169,87 @@ const setupWithoutSelectedFilterAttributes = () => {
 };

 describe( 'Filter by Attribute block', () => {
+	test( 'passes active attribute and price filters to the count hook', () => {
+		const queryState = {
+			attributes: [
+				{
+					attribute: 'pa_size',
+					operator: 'in',
+					slug: [ 'small' ],
+				},
+			],
+			min_price: '1500',
+			max_price: '4000',
+		};
+
+		renderBlock( {
+			initialUrl:
+				'http://woo.local/?filter_size=small&query_type_size=or&min_price=15&max_price=40',
+			queryState,
+		} );
+
+		expect( hooks.useCollectionData ).toHaveBeenCalledWith( {
+			queryAttribute: {
+				taxonomy: 'pa_size',
+				queryType: 'or',
+			},
+			queryState,
+			isEditor: false,
+		} );
+	} );
+
+	test( 'maps each product count to its term by ID', () => {
+		renderBlock( {
+			initialUrl: 'http://woo.local/',
+			productAttributeTerms: [
+				stubProductsAttributesTerms()[ 2 ],
+				stubProductsAttributesTerms()[ 0 ],
+				stubProductsAttributesTerms()[ 1 ],
+			],
+			collectionData: {
+				...stubCollectionData(),
+				attribute_counts: [
+					{ term: 26, count: 13 },
+					{ term: 27, count: 2 },
+					{ term: 25, count: 7 },
+				],
+			},
+			queryState: {
+				attributes: [
+					{
+						attribute: 'pa_size',
+						operator: 'in',
+						slug: [ 'large', 'medium', 'small' ],
+					},
+				],
+			},
+		} );
+
+		[
+			{ name: 'Large', count: 7 },
+			{ name: 'Medium', count: 13 },
+			{ name: 'Small', count: 2 },
+		].forEach( ( { name, count } ) => {
+			const label = screen.getByText( name ).closest( 'label' );
+			const visualCount = label?.querySelector(
+				'.wc-filter-element-label-list-count [aria-hidden="true"]'
+			);
+			const screenReaderCount = label?.querySelector(
+				'.screen-reader-text'
+			);
+
+			expect( label ).not.toBeNull();
+			expect(
+				within( label as HTMLLabelElement ).getByRole( 'checkbox' )
+			).toBeInTheDocument();
+			expect( visualCount ).toHaveTextContent( count.toString() );
+			expect( screenReaderCount ).toHaveTextContent(
+				`${ count } products`
+			);
+			expect( screenReaderCount ).toHaveClass( 'screen-reader-text' );
+		} );
+	} );
+
 	describe( 'Given no filter attribute is selected when page loads', () => {
 		test( 'should disable Apply button when page loads', () => {
 			const { applyButton } = setupWithoutSelectedFilterAttributes();
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/attribute-filter/test/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/attribute-filter/test/edit.tsx
new file mode 100644
index 00000000000..5204a33c65c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/attribute-filter/test/edit.tsx
@@ -0,0 +1,275 @@
+/**
+ * External dependencies
+ */
+import React, { useState } from '@wordpress/element';
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { InnerBlocks } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import FilterWrapperEdit from '../../filter-wrapper/edit';
+import AttributeFilterBlock from '../block';
+import AttributeFilterEdit from '../edit';
+
+jest.mock( '@wordpress/block-editor', () => ( {
+	...jest.requireActual( '@wordpress/block-editor' ),
+	useBlockProps: jest.fn( ( props = {} ) => props ),
+	BlockControls: jest.fn( ( { children } ) => <div>{ children }</div> ),
+	InspectorControls: jest.fn( ( { children } ) => <div>{ children }</div> ),
+	InnerBlocks: jest.fn( ( { template } ) => (
+		<section>
+			<h3>{ template[ 0 ][ 1 ].content }</h3>
+			<div
+				data-testid="locked-filter-child"
+				data-block-name={ template[ 1 ][ 0 ] }
+				data-lock-remove={ String( template[ 1 ][ 1 ].lock.remove ) }
+			/>
+		</section>
+	) ),
+} ) );
+
+jest.mock( '@wordpress/components', () => {
+	const element = jest.requireActual( '@wordpress/element' );
+
+	return {
+		...jest.requireActual( '@wordpress/components' ),
+		Disabled: jest.fn( ( { children } ) => <div>{ children }</div> ),
+		Notice: jest.fn( ( { children } ) => <div>{ children }</div> ),
+		PanelBody: jest.fn( ( { children } ) => <div>{ children }</div> ),
+		ToggleControl: jest.fn( ( { label, checked, onChange } ) => (
+			<label htmlFor={ String( label ) }>
+				<input
+					id={ String( label ) }
+					type="checkbox"
+					checked={ checked }
+					onChange={ ( event ) => onChange( event.target.checked ) }
+				/>
+				{ label }
+			</label>
+		) ),
+		withSpokenMessages: jest.fn( ( Component ) => Component ),
+		// The WordPress control scheduler is browser-owned; these adapters retain
+		// only the semantic radio/checkbox boundary for the real Edit callbacks.
+		__experimentalToggleGroupControl: jest.fn(
+			( { children, label, onChange, value } ) => (
+				<fieldset>
+					<legend>{ label }</legend>
+					{ element.Children.map( children, ( child ) =>
+						element.cloneElement( child, {
+							onSelect: onChange,
+							selectedValue: value,
+						} )
+					) }
+				</fieldset>
+			)
+		),
+		__experimentalToggleGroupControlOption: jest.fn(
+			( { label, onSelect, selectedValue, value } ) => (
+				<label htmlFor={ value }>
+					<input
+						id={ value }
+						type="radio"
+						checked={ selectedValue === value }
+						onChange={ () => onSelect( value ) }
+					/>
+					{ label }
+				</label>
+			)
+		),
+	};
+} );
+
+jest.mock( '@woocommerce/base-context/hooks', () => {
+	const attributeTerms = [
+		{ id: 11, name: 'Small', slug: 'small' },
+		{ id: 12, name: 'Medium', slug: 'medium' },
+		{ id: 13, name: 'Large', slug: 'large' },
+	];
+	const collectionData = {
+		price_range: null,
+		attribute_counts: [
+			{ term: 11, count: 1 },
+			{ term: 12, count: 1 },
+			{ term: 13, count: 1 },
+		],
+		rating_counts: null,
+		stock_status_counts: null,
+	};
+
+	return {
+		...jest.requireActual( '@woocommerce/base-context/hooks' ),
+		useCollection: jest.fn( () => ( {
+			results: attributeTerms,
+			isLoading: false,
+		} ) ),
+		useCollectionData: jest.fn( () => ( {
+			data: collectionData,
+			isLoading: false,
+		} ) ),
+		useQueryStateByContext: jest.fn( () => [ {} ] ),
+		useQueryStateByKey: jest.fn( () => [ [], jest.fn() ] ),
+	};
+} );
+
+jest.mock( '@woocommerce/settings', () => {
+	const attributes = [
+		{
+			attribute_id: '1',
+			attribute_name: 'size',
+			attribute_label: 'Size',
+			attribute_orderby: 'menu_order',
+		},
+	];
+
+	return {
+		...jest.requireActual( '@woocommerce/settings' ),
+		getSetting: jest.fn( ( key, defaultValue ) =>
+			key === 'attributes' ? attributes : defaultValue
+		),
+		getSettingWithCoercion: jest.fn( ( key, defaultValue ) =>
+			key === 'hasFilterableProducts' ? true : defaultValue
+		),
+	};
+} );
+
+jest.mock( '@wordpress/a11y', () => ( {
+	...jest.requireActual( '@wordpress/a11y' ),
+	speak: jest.fn(),
+} ) );
+
+afterEach( () => {
+	jest.clearAllMocks();
+	jest.restoreAllMocks();
+} );
+
+describe( 'Attribute Filter editor ownership', () => {
+	it( 'seeds the attribute-filter wrapper template', () => {
+		const WrapperEdit = FilterWrapperEdit as unknown as React.ComponentType<
+			Record< string, unknown >
+		>;
+
+		render(
+			<WrapperEdit
+				attributes={ {
+					filterType: 'attribute-filter',
+					heading: 'Filter by attribute',
+				} }
+				clientId="wrapper-client-id"
+			/>
+		);
+
+		expect(
+			screen.getByRole( 'heading', {
+				level: 3,
+				name: 'Filter by attribute',
+			} )
+		).toBeInTheDocument();
+		expect( screen.getByTestId( 'locked-filter-child' ) ).toHaveAttribute(
+			'data-block-name',
+			'woocommerce/attribute-filter'
+		);
+		expect( screen.getByTestId( 'locked-filter-child' ) ).toHaveAttribute(
+			'data-lock-remove',
+			'true'
+		);
+
+		const innerBlocksProps = ( InnerBlocks as unknown as jest.Mock ).mock
+			.calls[ 0 ][ 0 ];
+		expect( innerBlocksProps.allowedBlocks ).toEqual( [ 'core/heading' ] );
+		expect( innerBlocksProps.template ).toEqual( [
+			[ 'core/heading', { content: 'Filter by attribute', level: 3 } ],
+			[
+				'woocommerce/attribute-filter',
+				{ heading: '', lock: { remove: true } },
+			],
+		] );
+	} );
+
+	it( 'maps Attribute display and Apply controls to preview behavior', async () => {
+		const user = userEvent.setup();
+		const setAttributes = jest.fn();
+		const initialAttributes: React.ComponentProps<
+			typeof AttributeFilterBlock
+		>[ 'attributes' ] = {
+			attributeId: 1,
+			displayStyle: 'list',
+			heading: '',
+			headingLevel: 3,
+			isPreview: false,
+			queryType: 'or',
+			selectType: 'multiple',
+			showCounts: false,
+			showFilterButton: false,
+		};
+		const Edit = AttributeFilterEdit as unknown as React.ComponentType<
+			Record< string, unknown >
+		>;
+		const StatefulAttributeFilter = () => {
+			const [ attributes, setCurrentAttributes ] =
+				useState( initialAttributes );
+			const updateAttributes = (
+				updates: Partial< typeof attributes >
+			) => {
+				setAttributes( updates );
+				setCurrentAttributes( ( currentAttributes ) => ( {
+					...currentAttributes,
+					...updates,
+				} ) );
+			};
+
+			return (
+				<Edit
+					attributes={ attributes }
+					clientId="attribute-filter-client-id"
+					setAttributes={ updateAttributes }
+				/>
+			);
+		};
+
+		render( <StatefulAttributeFilter /> );
+
+		for ( const name of [ 'Small', 'Medium', 'Large' ] ) {
+			expect(
+				await screen.findByRole( 'checkbox', { name } )
+			).toBeVisible();
+		}
+		expect(
+			screen.queryByRole( 'button', { name: /apply attribute filter/i } )
+		).not.toBeInTheDocument();
+
+		// The @wordpress/element state update falls outside userEvent's act boundary.
+		// eslint-disable-next-line testing-library/no-unnecessary-act
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'radio', { name: 'Dropdown' } )
+			);
+		} );
+		expect( setAttributes ).toHaveBeenCalledTimes( 1 );
+		expect( setAttributes ).toHaveBeenCalledWith( {
+			displayStyle: 'dropdown',
+		} );
+		expect( await screen.findByRole( 'combobox' ) ).toBeVisible();
+
+		setAttributes.mockClear();
+		// The @wordpress/element state update falls outside userEvent's act boundary.
+		// eslint-disable-next-line testing-library/no-unnecessary-act
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'checkbox', {
+					name: "Show 'Apply filters' button",
+				} )
+			);
+		} );
+		expect( setAttributes ).toHaveBeenCalledTimes( 1 );
+		expect( setAttributes ).toHaveBeenCalledWith( {
+			showFilterButton: true,
+		} );
+		expect(
+			await screen.findByRole( 'button', {
+				name: /apply attribute filter/i,
+			} )
+		).toBeVisible();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-legacy-attribute-filter b/plugins/woocommerce/client/blocks/changelog/testops-234-legacy-attribute-filter
new file mode 100644
index 00000000000..7afc4a0d34c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-legacy-attribute-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce legacy Filter by Attribute E2E tests from 11 to 3; PHPUnit owns the OR attribute counts and Jest owns the count hand-off, the collection-data request, and the editor controls.
+
diff --git a/plugins/woocommerce/tests/e2e/content-templates/blocks/template_archive-product_filters-with-product-collection.handlebars b/plugins/woocommerce/tests/e2e/content-templates/blocks/template_archive-product_filters-with-product-collection.handlebars
index 710f2cec15d..561e0061c58 100644
--- a/plugins/woocommerce/tests/e2e/content-templates/blocks/template_archive-product_filters-with-product-collection.handlebars
+++ b/plugins/woocommerce/tests/e2e/content-templates/blocks/template_archive-product_filters-with-product-collection.handlebars
@@ -53,7 +53,7 @@
 			<h3 class='wp-block-heading'>Filter by attribute</h3>
 			<!-- /wp:heading -->

-			<!-- wp:woocommerce/attribute-filter {"attributeId":2,"heading":"","lock":{"remove":true}} -->
+			<!-- wp:woocommerce/attribute-filter {"attributeId":{{#if attributeId}}{{attributeId}}{{else}}2{{/if}},"heading":"","lock":{"remove":true}} -->
 			<div class="wp-block-woocommerce-attribute-filter is-loading"></div>
 			<!-- /wp:woocommerce/attribute-filter -->
 		</div>
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/attributes-filter/attribute-filter.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/attributes-filter/attribute-filter.block_theme.spec.ts
index fa7d0f0f2bf..7847a366b5f 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/attributes-filter/attribute-filter.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/attributes-filter/attribute-filter.block_theme.spec.ts
@@ -7,7 +7,9 @@ import {
 	wpCLI,
 	TemplateCompiler,
 	BLOCK_THEME_SLUG,
+	getProductAttributeIds,
 } from '@woocommerce/e2e-utils';
+import type { Page } from '@playwright/test';

 const blockData = {
 	name: 'Filter by Attribute',
@@ -15,15 +17,23 @@ const blockData = {
 	urlSearchParamWhenFilterIsApplied: 'filter_size=small&query_type_size=or',
 };

+const blockifiedTemplateOption =
+	'wc_blocks_use_blockified_product_grid_block_as_template';
+
 const test = base.extend< { templateCompiler: TemplateCompiler } >( {
-	templateCompiler: async ( { requestUtils }, use ) => {
+	templateCompiler: async ( { requestUtils }, provideTemplateCompiler ) => {
 		const compiler = await requestUtils.createTemplateFromFile(
 			'archive-product_filters-with-product-collection'
 		);
-		await use( compiler );
+		await provideTemplateCompiler( compiler );
 	},
 } );

+const getProductCollectionTitles = ( page: Page ) =>
+	page.locator(
+		'.wp-block-woocommerce-product-template .wp-block-post-title'
+	);
+
 test.describe( `${ blockData.name } Block`, () => {
 	test.beforeEach( async ( { admin, editor } ) => {
 		await admin.createNewPost();
@@ -41,74 +51,49 @@ test.describe( `${ blockData.name } Block`, () => {
 		await editor.openDocumentSettingsSidebar();
 	} );

-	test( "should allow changing the block's title", async ( { editor } ) => {
+	test( 'edits the title, display style, and Apply behavior', async ( {
+		page,
+		editor,
+	} ) => {
 		const textSelector =
 			'.wp-block-woocommerce-filter-wrapper .wp-block-heading';
-
 		const title = 'New Title';

 		await editor.canvas.locator( textSelector ).fill( title );
-
 		await expect( editor.canvas.locator( textSelector ) ).toHaveText(
 			title
 		);
-	} );

-	test( 'should allow changing the display style', async ( {
-		page,
-		editor,
-	} ) => {
 		const attributeFilter = await editor.getBlockByName( blockData.slug );
 		await editor.selectBlocks( attributeFilter );
-
 		await expect(
-			editor.canvas.getByRole( 'checkbox', { name: 'Small' } )
+			attributeFilter.getByRole( 'checkbox', { name: 'Small' } )
 		).toBeVisible();

 		await page.getByLabel( 'DropDown' ).click();
-
-		await expect(
-			attributeFilter.getByRole( 'checkbox', {
-				name: 'Small',
-			} )
-		).toBeHidden();
-
 		await expect(
-			editor.canvas.getByRole( 'checkbox', { name: 'Small' } )
+			attributeFilter.getByRole( 'checkbox', { name: 'Small' } )
 		).toBeHidden();
-
-		await expect( editor.canvas.getByRole( 'combobox' ) ).toBeVisible();
-	} );
-
-	test( 'should allow toggling the visibility of the filter button', async ( {
-		page,
-		editor,
-	} ) => {
-		const attributeFilter = await editor.getBlockByName( blockData.slug );
-		await editor.selectBlocks( attributeFilter );
-
+		await expect( attributeFilter.getByRole( 'combobox' ) ).toBeVisible();
 		await expect(
-			attributeFilter.getByRole( 'button', {
-				name: 'Apply',
-			} )
+			attributeFilter.getByRole( 'button', { name: 'Apply' } )
 		).toBeHidden();

 		await page.getByText( "Show 'Apply filters' button" ).click();
-
 		await expect(
-			attributeFilter.getByRole( 'button', {
-				name: 'Apply',
-			} )
+			attributeFilter.getByRole( 'button', { name: 'Apply' } )
 		).toBeVisible();
 	} );
 } );

 test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
-	test.beforeEach( async ( { admin, page, editor } ) => {
-		await wpCLI(
-			'option update wc_blocks_use_blockified_product_grid_block_as_template false'
-		);
-
+	test( 'filters the PHP classic template by attribute', async ( {
+		admin,
+		editor,
+		frontendUtils,
+		page,
+	} ) => {
+		await wpCLI( `option update ${ blockifiedTemplateOption } false` );
 		await admin.visitSiteEditor( {
 			postId: `${ BLOCK_THEME_SLUG }//archive-product`,
 			postType: 'wp_template',
@@ -126,98 +111,63 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {

 		await attributeFilter.getByText( 'Size' ).click();
 		await attributeFilter.getByText( 'Done' ).click();
-
 		await editor.saveSiteEditorEntities( {
 			isOnlyCurrentEntityDirty: true,
 		} );
 		await page.goto( '/shop' );
-	} );

-	test( 'should show all products', async ( { frontendUtils, page } ) => {
 		const legacyTemplate = await frontendUtils.getBlockByName(
 			'woocommerce/legacy-template'
 		);
-
-		const products = legacyTemplate
-			.getByRole( 'list' )
-			.locator( '.product' );
-
-		await expect( products ).toHaveCount( 16 );
-
-		await expect(
-			page.getByRole( 'checkbox', { name: 'Small' } )
-		).toBeVisible();
-
-		await expect(
-			page.getByRole( 'checkbox', { name: 'Medium' } )
-		).toBeVisible();
-
-		await expect(
-			page.getByRole( 'checkbox', { name: 'Large' } )
-		).toBeVisible();
-	} );
-
-	test( 'should show only products that match the filter', async ( {
-		frontendUtils,
-		page,
-	} ) => {
-		await page.getByRole( 'checkbox', { name: 'Small' } ).click();
-
-		const legacyTemplate = await frontendUtils.getBlockByName(
-			'woocommerce/legacy-template'
+		const productTitles = legacyTemplate.locator(
+			'.woocommerce-loop-product__title'
 		);

-		const products = legacyTemplate
-			.getByRole( 'list' )
-			.locator( '.product' );
+		await expect( productTitles.first() ).toBeVisible();
+		expect( await productTitles.allTextContents() ).not.toHaveLength( 0 );
+		for ( const name of [ 'Small', 'Medium', 'Large' ] ) {
+			await expect(
+				page.getByRole( 'checkbox', { name } )
+			).toBeVisible();
+		}

+		await page.getByRole( 'checkbox', { name: 'Small' } ).click();
 		await expect( page ).toHaveURL(
 			new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
 		);
-
-		await expect( products ).toHaveCount( 1 );
+		await expect( productTitles ).toHaveText( [ 'V-Neck T-Shirt' ] );
 	} );
 } );

 test.describe( `${ blockData.name } Block - with Product Collection`, () => {
-	test( 'should show all products', async ( { page, templateCompiler } ) => {
-		await templateCompiler.compile();
-
-		await page.goto( '/shop' );
-		const products = page
-			.locator( '.wp-block-woocommerce-product-template' )
-			.getByRole( 'listitem' );
-
-		await expect( products ).toHaveCount( 16 );
-	} );
-
-	test( 'should show only products that match the filter', async ( {
+	test( 'filters Product Collection automatically and defers changes until Apply when configured', async ( {
 		page,
+		admin,
+		editor,
 		templateCompiler,
 	} ) => {
-		await templateCompiler.compile();
+		await page.clock.install();
+		const { sizeAttributeId } = await getProductAttributeIds();
+		const template = await templateCompiler.compile( {
+			attributeId: sizeAttributeId,
+		} );
+		const productTitles = getProductCollectionTitles( page );

 		await page.goto( '/shop' );
-		await page.getByRole( 'checkbox', { name: 'Small' } ).click();
+		await expect( productTitles.first() ).toBeVisible();
+		const automaticBaseline = ( await productTitles.allTextContents() ).map(
+			( title ) => title.trim()
+		);
+		expect( automaticBaseline ).not.toHaveLength( 0 );
+		await expect(
+			page.getByRole( 'checkbox', { name: 'Small' } )
+		).toBeVisible();

+		await page.getByRole( 'checkbox', { name: 'Small' } ).click();
 		await expect( page ).toHaveURL(
 			new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
 		);
-
-		const products = page
-			.locator( '.wp-block-woocommerce-product-template' )
-			.getByRole( 'listitem' );
-
-		await expect( products ).toHaveCount( 1 );
-	} );
-
-	test( 'should refresh the page only if the user clicks on button', async ( {
-		page,
-		admin,
-		editor,
-		templateCompiler,
-	} ) => {
-		const template = await templateCompiler.compile();
+		await expect( productTitles ).toHaveText( [ 'V-Neck T-Shirt' ] );

 		await admin.visitSiteEditor( {
 			postId: template.id,
@@ -225,31 +175,59 @@ test.describe( `${ blockData.name } Block - with Product Collection`, () => {
 			canvas: 'edit',
 		} );

-		const attributeFilterControl = await editor.getBlockByName(
-			blockData.slug
-		);
-		await expect( attributeFilterControl ).toBeVisible();
-		await editor.selectBlocks( attributeFilterControl );
+		const attributeFilter = await editor.getBlockByName( blockData.slug );
+		await expect( attributeFilter ).toBeVisible();
+		await editor.selectBlocks( attributeFilter );
 		await editor.openDocumentSettingsSidebar();
-
 		await page.getByText( "Show 'Apply filters' button" ).click();
-
 		await editor.saveSiteEditorEntities( {
 			isOnlyCurrentEntityDirty: true,
 		} );
-		await page.goto( '/shop' );
-
-		await page.getByRole( 'checkbox', { name: 'Small' } ).click();
-		await page.getByRole( 'button', { name: 'Apply' } ).click();

+		await page.goto( '/shop' );
+		await expect( productTitles.first() ).toBeVisible();
+		const smallCheckbox = page.getByRole( 'checkbox', { name: 'Small' } );
+		await expect( smallCheckbox ).toBeVisible();
+		await page.clock.pauseAt(
+			( await page.evaluate( () => Date.now() ) ) + 1_000
+		);
+		const deferredBaseline = ( await productTitles.allTextContents() ).map(
+			( title ) => title.trim()
+		);
+		expect( deferredBaseline ).not.toHaveLength( 0 );
+		const deferredUrl = page.url();
+
+		await smallCheckbox.click();
+		await page.clock.runFor( 501 );
+		await page.evaluate(
+			() =>
+				new Promise< void >( ( resolve ) => {
+					const channel = new MessageChannel();
+					channel.port1.addEventListener(
+						'message',
+						() => {
+							channel.port1.close();
+							channel.port2.close();
+							resolve();
+						},
+						{ once: true }
+					);
+					channel.port1.start();
+					channel.port2.postMessage( null );
+				} )
+		);
+		await expect( smallCheckbox ).toBeChecked();
+		const applyButton = page.getByRole( 'button', { name: 'Apply' } );
+		await expect( applyButton ).toBeVisible();
+		await expect( applyButton ).toBeEnabled();
+		await expect( page ).toHaveURL( deferredUrl );
+		await expect( productTitles ).toHaveText( deferredBaseline );
+
+		await page.clock.resume();
+		await applyButton.click();
 		await expect( page ).toHaveURL(
 			new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
 		);
-
-		const products = page
-			.locator( '.wp-block-woocommerce-product-template' )
-			.getByRole( 'listitem' );
-
-		await expect( products ).toHaveCount( 1 );
+		await expect( productTitles ).toHaveText( [ 'V-Neck T-Shirt' ] );
 	} );
 } );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/attributes-filter/filter-products-by-attributes-count.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/attributes-filter/filter-products-by-attributes-count.block_theme.spec.ts
deleted file mode 100644
index 1b55aba907c..00000000000
--- a/plugins/woocommerce/tests/e2e/tests/blocks/attributes-filter/filter-products-by-attributes-count.block_theme.spec.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * External dependencies
- */
-import { test as base, expect, PostCompiler } from '@woocommerce/e2e-utils';
-
-const test = base.extend< {
-	postCompiler: PostCompiler;
-} >( {
-	postCompiler: async ( { requestUtils }, use ) => {
-		const post = await requestUtils.createPostFromFile(
-			'filters-with-all-products'
-		);
-
-		await use( post );
-	},
-} );
-
-test.describe( 'Filter by Attributes Block - with All products Block', () => {
-	test( 'should show correct attrs count (color=blue|query_type_color=or)', async ( {
-		page,
-		postCompiler,
-	} ) => {
-		const post = await postCompiler.compile( {} );
-
-		await page.goto(
-			`${ post.link }?filter_color=blue&query_type_color=or`
-		);
-
-		const expectedValues = [ '4', '2', '3', '4', '1' ];
-
-		await expect(
-			page
-				.locator( 'ul.wc-block-attribute-filter-list' )
-				.first()
-				.locator(
-					'> li:not([class^="is-loading"]) .wc-filter-element-label-list-count > span:not([class^="screen-reader"])'
-				)
-		).toHaveText( expectedValues );
-	} );
-
-	test( 'should show correct attrs count (color=blue,gray|query_type_color=or)', async ( {
-		page,
-		postCompiler,
-	} ) => {
-		const post = await postCompiler.compile( {} );
-
-		await page.goto(
-			`${ post.link }?filter_color=blue,gray&query_type_color=or`
-		);
-
-		const expectedValues = [ '4', '2', '3', '4', '1' ];
-
-		await expect(
-			page
-				.locator( 'ul.wc-block-attribute-filter-list' )
-				.first()
-				.locator(
-					'> li:not([class^="is-loading"]) .wc-filter-element-label-list-count > span:not([class^="screen-reader"])'
-				)
-		).toHaveText( expectedValues );
-	} );
-
-	test( 'should show correct attrs count (color=blue|query_type_color=or|min_price=15|max_price=40)', async ( {
-		page,
-		postCompiler,
-	} ) => {
-		const post = await postCompiler.compile( {} );
-
-		await page.goto(
-			`${ post.link }?filter_color=blue&query_type_color=or&min_price=15&max_price=40`
-		);
-
-		const expectedValues = [ '2', '2', '2', '3', '1' ];
-
-		await expect(
-			page
-				.locator( 'ul.wc-block-attribute-filter-list' )
-				.first()
-				.locator(
-					'> li:not([class^="is-loading"]) .wc-filter-element-label-list-count > span:not([class^="screen-reader"])'
-				)
-		).toHaveText( expectedValues );
-	} );
-} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-frontend.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-frontend.block_theme.spec.ts
index eea5b1310e5..184e894100d 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-frontend.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/attribute-filter-frontend.block_theme.spec.ts
@@ -5,7 +5,7 @@ import {
 	TemplateCompiler,
 	test as base,
 	expect,
-	wpCLI,
+	getProductAttributeIds,
 } from '@woocommerce/e2e-utils';

 const GRAY_PRODUCT_TITLES = [ 'T-Shirt', 'T-Shirt with Logo' ];
@@ -17,34 +17,6 @@ const COLOR_ATTRIBUTES_WITH_COUNTS = [
 	'Yellow (1)',
 ];

-const getColorAttributeId = async () => {
-	const { stdout } = await wpCLI(
-		'wc product_attribute list --format=json --user=1'
-	);
-	const firstBracket = stdout.indexOf( '[' );
-	const lastBracket = stdout.lastIndexOf( ']' );
-
-	if ( firstBracket < 0 || lastBracket <= firstBracket ) {
-		throw new Error( 'Product attribute CLI output did not contain JSON.' );
-	}
-
-	const attributes = JSON.parse(
-		stdout.slice( firstBracket, lastBracket + 1 )
-	) as Array< { id: number | string; name: string; slug: string } >;
-	const colorAttributes = attributes.filter(
-		( attribute ) =>
-			attribute.name === 'Color' && attribute.slug === 'pa_color'
-	);
-
-	expect( colorAttributes ).toHaveLength( 1 );
-	const attributeId = Number( colorAttributes[ 0 ].id );
-	expect( Number.isSafeInteger( attributeId ) && attributeId > 0 ).toBe(
-		true
-	);
-
-	return attributeId;
-};
-
 const test = base.extend< { templateCompiler: TemplateCompiler } >( {
 	templateCompiler: async ( { requestUtils }, use ) => {
 		const compiler = await requestUtils.createTemplateFromFile(
@@ -57,7 +29,7 @@ const test = base.extend< { templateCompiler: TemplateCompiler } >( {
 test.describe( 'woocommerce/product-filter-attribute - Frontend', () => {
 	test.describe( 'With default display style', () => {
 		test.beforeEach( async ( { templateCompiler, page } ) => {
-			const colorAttributeId = await getColorAttributeId();
+			const { colorAttributeId } = await getProductAttributeIds();
 			await templateCompiler.compile( {
 				attributes: {
 					attributeId: colorAttributeId,
@@ -145,7 +117,7 @@ test.describe( 'woocommerce/product-filter-attribute - Frontend', () => {

 	test.describe( 'With show counts enabled', () => {
 		test.beforeEach( async ( { templateCompiler } ) => {
-			const colorAttributeId = await getColorAttributeId();
+			const { colorAttributeId } = await getProductAttributeIds();
 			await templateCompiler.compile( {
 				attributes: {
 					attributeId: colorAttributeId,
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
index ff965fc6611..86965de10ee 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
@@ -196,6 +196,108 @@ class ProductCollectionData extends ControllerTestCase {
 		$this->assertTrue( property_exists( $data['attribute_counts'][0], 'count' ) );
 	}

+	/**
+	 * @testdox OR attribute counts remove the counted facet while preserving price filters.
+	 */
+	public function test_attribute_count_matrix(): void {
+		$fixtures    = new FixtureData();
+		$attribute   = $this->create_product_attribute( 'color', array( 'blue', 'gray', 'red' ) );
+		$term_ids    = array(
+			'blue' => $attribute['term_ids'][0],
+			'gray' => $attribute['term_ids'][1],
+			'red'  => $attribute['term_ids'][2],
+		);
+		$product_ids = array();
+
+		try {
+			$product_ids[] = $this->create_attribute_count_product( $fixtures, $attribute, array( $term_ids['blue'] ), 10 );
+			$product_ids[] = $this->create_attribute_count_product( $fixtures, $attribute, array( $term_ids['blue'], $term_ids['gray'] ), 20 );
+			$product_ids[] = $this->create_attribute_count_product( $fixtures, $attribute, array( $term_ids['gray'] ), 30 );
+			$product_ids[] = $this->create_attribute_count_product( $fixtures, $attribute, array( $term_ids['red'] ), 50 );
+
+			$unfiltered_counts = array(
+				$term_ids['blue'] => 2,
+				$term_ids['gray'] => 2,
+				$term_ids['red']  => 1,
+			);
+			$test_cases        = array(
+				'selected blue'                  => array(
+					'attributes' => array(
+						array(
+							'attribute' => 'pa_color',
+							'operator'  => 'in',
+							'slug'      => array( 'blue-slug' ),
+						),
+					),
+					'expected'   => $unfiltered_counts,
+				),
+				'selected blue and gray'         => array(
+					'attributes' => array(
+						array(
+							'attribute' => 'pa_color',
+							'operator'  => 'in',
+							'slug'      => array( 'blue-slug', 'gray-slug' ),
+						),
+					),
+					'expected'   => $unfiltered_counts,
+				),
+				'selected blue with price range' => array(
+					'attributes' => array(
+						array(
+							'attribute' => 'pa_color',
+							'operator'  => 'in',
+							'slug'      => array( 'blue-slug' ),
+						),
+					),
+					'min_price'  => '1500',
+					'max_price'  => '4000',
+					'expected'   => array(
+						$term_ids['blue'] => 1,
+						$term_ids['gray'] => 2,
+					),
+				),
+			);
+
+			foreach ( $test_cases as $case_name => $test_case ) {
+				$params = array(
+					'attributes'                 => $test_case['attributes'],
+					'calculate_attribute_counts' => array(
+						array(
+							'taxonomy'   => 'pa_color',
+							'query_type' => 'or',
+						),
+					),
+				);
+
+				if ( isset( $test_case['min_price'] ) ) {
+					$params['min_price'] = $test_case['min_price'];
+					$params['max_price'] = $test_case['max_price'];
+				}
+
+				$response = $this->dispatch_collection_data_request( $params );
+				$counts   = array();
+
+				foreach ( $response->get_data()['attribute_counts'] as $count ) {
+					$counts[ $count->term ] = $count->count;
+				}
+
+				ksort( $counts );
+				ksort( $test_case['expected'] );
+
+				$this->assertSame( 200, $response->get_status(), "{$case_name}: the route should accept the count request." );
+				$this->assertSame( $test_case['expected'], $counts, "{$case_name}: counts should ignore the active color facet and retain other filters." );
+			}
+		} finally {
+			foreach ( $product_ids as $product_id ) {
+				$product = wc_get_product( $product_id );
+
+				if ( $product ) {
+					$product->delete( true );
+				}
+			}
+		}
+	}
+
 	/**
 	 * Test calculation method.
 	 */
@@ -796,6 +898,35 @@ class ProductCollectionData extends ControllerTestCase {
 		return $attribute;
 	}

+	/**
+	 * Create a simple product for the attribute-count matrix.
+	 *
+	 * @param FixtureData $fixtures Fixture data helper.
+	 * @param array       $attribute Attribute taxonomy data.
+	 * @param int[]       $term_ids Attribute term IDs assigned to the product.
+	 * @param int         $price Product price.
+	 * @return int Product ID.
+	 */
+	private function create_attribute_count_product( FixtureData $fixtures, array $attribute, array $term_ids, int $price ): int {
+		$product_attribute = new \WC_Product_Attribute();
+		$product_attribute->set_id( $attribute['attribute_id'] );
+		$product_attribute->set_name( $attribute['attribute_taxonomy'] );
+		$product_attribute->set_options( $term_ids );
+		$product_attribute->set_visible( true );
+
+		$product = $fixtures->get_simple_product(
+			array(
+				'name'          => "Attribute count product {$price}",
+				'regular_price' => $price,
+				'stock_status'  => 'instock',
+			)
+		);
+		$product->set_attributes( array( $product_attribute ) );
+		$product->save();
+
+		return $product->get_id();
+	}
+
 	/**
 	 * Create the size product attribute taxonomy.
 	 *