Commit 600427826ac for woocommerce

commit 600427826ac2a594b006b4761ebc967f06b33f8d
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Mon Jun 1 16:52:23 2026 +0700

    Fix non-visual attribute term responses (#65432)

    * Fix non-visual attribute term responses

    * Add non-visual attribute term docs example

    * Make visual attribute term data opt-in

diff --git a/docs/apis/store-api/resources-endpoints/product-attribute-terms.md b/docs/apis/store-api/resources-endpoints/product-attribute-terms.md
index d921828127c..99840e410ca 100644
--- a/docs/apis/store-api/resources-endpoints/product-attribute-terms.md
+++ b/docs/apis/store-api/resources-endpoints/product-attribute-terms.md
@@ -5,17 +5,29 @@ GET /products/attributes/:id/terms
 GET /products/attributes/:id/terms?orderby=slug
 ```

-| Attribute | Type    | Required | Description                                                                                                   |
-| :-------- | :------ | :------: |:--------------------------------------------------------------------------------------------------------------|
-| `id`      | integer |   Yes    | The ID of the attribute to retrieve terms for.                                                                |
-| `order`   | string  |    no    | Order ascending or descending. Allowed values: `asc`, `desc`                                                  |
-| `orderby` | string  |    no    | Sort collection by object attribute. Allowed values: `id`, `name`, `name_num`, `slug`, `count`, `menu_order`. |
+| Attribute               | Type    | Required | Description                                                                                                   |
+| :---------------------- | :------ | :------: | :------------------------------------------------------------------------------------------------------------ |
+| `id`                    | integer |   Yes    | The ID of the attribute to retrieve terms for.                                                                |
+| `order`                 | string  |    no    | Order ascending or descending. Allowed values: `asc`, `desc`                                                  |
+| `orderby`               | string  |    no    | Sort collection by object attribute. Allowed values: `id`, `name`, `name_num`, `slug`, `count`, `menu_order`. |
+| `__experimental_visual` | boolean |    no    | If true, include experimental visual swatch data for `wc-visual` attribute terms.                             |
+
+## Visual response fields
+
+The following fields are included only when `__experimental_visual=true` is passed for `wc-visual` attribute terms.
+Other attribute types keep the default term response without `__experimentalVisual`.
+
+| Attribute                    | Type   | Description                                                                                                    |
+| :--------------------------- | :----- | :------------------------------------------------------------------------------------------------------------- |
+| `__experimentalVisual`       | object | Experimental visual swatch data for `wc-visual` attribute terms.                                               |
+| `__experimentalVisual.type`  | string | Visual swatch type. Allowed values: `color`, `image`, `none`.                                                  |
+| `__experimentalVisual.value` | string | Visual swatch value. Returns a hex color for `color`, an image URL for `image`, or an empty string for `none`. |

 ```sh
-curl "https://example-store.com/wp-json/wc/store/v1/products/attributes/1/terms"
+curl "https://example-store.com/wp-json/wc/store/v1/products/attributes/1/terms?__experimental_visual=true"
 ```

-**Example response:**
+**Example response for visual attribute terms:**

 ```json
 [
@@ -23,13 +35,40 @@ curl "https://example-store.com/wp-json/wc/store/v1/products/attributes/1/terms"
 		"id": 22,
 		"name": "Blue",
 		"slug": "blue",
-		"count": 5
+		"description": "",
+		"parent": 0,
+		"count": 5,
+		"__experimentalVisual": {
+			"type": "color",
+			"value": "#1e73be"
+		}
 	},
 	{
 		"id": 48,
 		"name": "Burgundy",
 		"slug": "burgundy",
-		"count": 1
+		"description": "",
+		"parent": 0,
+		"count": 1,
+		"__experimentalVisual": {
+			"type": "image",
+			"value": "https://example-store.com/wp-content/uploads/2026/06/burgundy-swatch.jpg"
+		}
+	}
+]
+```
+
+**Example response for non-visual attribute terms:**
+
+```json
+[
+	{
+		"id": 12,
+		"name": "Large",
+		"slug": "large",
+		"description": "",
+		"parent": 0,
+		"count": 7
 	}
 ]
 ```
diff --git a/plugins/woocommerce/changelog/fix-nonvisual-attribute-term-response b/plugins/woocommerce/changelog/fix-nonvisual-attribute-term-response
new file mode 100644
index 00000000000..aa28eb2d3f9
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-nonvisual-attribute-term-response
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep Store API visual attribute term data opt-in with `__experimental_visual`.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
index 6c7d5bdbcae..38fdc41d378 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
@@ -96,7 +96,11 @@ function AttributeItem( { blocks, isSelected, onSelect }: AttributeItemProps ) {
 		resourceName: 'products/attributes/terms',
 		resourceValues: [ attribute?.id || 0 ],
 		shouldSelect: !! attribute?.id && termIds.length > 0,
-		query: { include: termIds, hide_empty: false },
+		query: {
+			include: termIds,
+			hide_empty: false,
+			__experimental_visual: true,
+		},
 	} );
 	const visualByTermId = useMemo( () => {
 		return attributeTerms.reduce< Record< number, VisualAttributeTerm > >(
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
index 745d5d045e4..228a4f43acb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
@@ -65,7 +65,11 @@ const Edit = ( props: EditProps ) => {
 			resourceName: 'products/attributes/terms',
 			resourceValues: [ attributeObject?.id || 0 ],
 			shouldSelect: !! attributeObject?.id,
-			query: { orderby: 'menu_order', hide_empty: hideEmpty },
+			query: {
+				orderby: 'menu_order',
+				hide_empty: hideEmpty,
+				__experimental_visual: true,
+			},
 		} );

 	const { data: filteredCounts, isLoading: isFilterCountsLoading } =
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductAttributeTerms.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductAttributeTerms.php
index bcdb2cf271b..4c49889b830 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductAttributeTerms.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductAttributeTerms.php
@@ -1,6 +1,7 @@
 <?php
 namespace Automattic\WooCommerce\StoreApi\Routes\V1;

+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
 use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductAttributeTermSchema;

@@ -70,13 +71,52 @@ class ProductAttributeTerms extends AbstractTermsRoute {
 	 * @return array
 	 */
 	public function get_collection_params() {
-		$params                      = parent::get_collection_params();
-		$params['orderby']['enum'][] = 'menu_order';
-		$params['orderby']['enum'][] = 'name_num';
-		$params['orderby']['enum'][] = 'id';
+		$params                          = parent::get_collection_params();
+		$params['orderby']['enum'][]     = 'menu_order';
+		$params['orderby']['enum'][]     = 'name_num';
+		$params['orderby']['enum'][]     = 'id';
+		$params['__experimental_visual'] = array(
+			'description'       => __( 'If true, include experimental visual swatch data for wc-visual attribute terms.', 'woocommerce' ),
+			'type'              => 'boolean',
+			'default'           => false,
+			'sanitize_callback' => 'wc_string_to_bool',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
 		return $params;
 	}

+	/**
+	 * Prepare a single item for response.
+	 *
+	 * @param mixed            $item Item to format to schema.
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response $response Response data.
+	 */
+	public function prepare_item_for_response( $item, \WP_REST_Request $request ) {
+		$response = parent::prepare_item_for_response( $item, $request );
+
+		if (
+			! wc_string_to_bool( $request['__experimental_visual'] ) ||
+			! ( $item instanceof \WP_Term ) ||
+			! VisualAttributeTermMeta::is_visual_attribute_taxonomy( $item->taxonomy )
+		) {
+			return $response;
+		}
+
+		$data = $response->get_data();
+
+		$data[ ProductAttributeTermSchema::VISUAL_PROPERTY_NAME ] = VisualAttributeTermMeta::get_term_visual(
+			(int) $item->term_id
+		);
+
+		$response->set_data( $data );
+
+		return $response;
+	}
+
 	/**
 	 * Get a collection of attribute terms.
 	 *
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductAttributeTermSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductAttributeTermSchema.php
index c85b1f52cce..24ff4011d0a 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductAttributeTermSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductAttributeTermSchema.php
@@ -43,22 +43,6 @@ class ProductAttributeTermSchema extends TermSchema {
 		return $schema;
 	}

-	/**
-	 * Convert a product attribute term object into an object suitable for the response.
-	 *
-	 * @param \WP_Term $term Term object.
-	 * @return array
-	 */
-	public function get_item_response( $term ) {
-		$response = parent::get_item_response( $term );
-
-		$response[ self::VISUAL_PROPERTY_NAME ] = VisualAttributeTermMeta::is_visual_attribute_taxonomy( $term->taxonomy )
-			? VisualAttributeTermMeta::get_term_visual( (int) $term->term_id )
-			: null;
-
-		return $response;
-	}
-
 	/**
 	 * Get the visual data property schema.
 	 *
@@ -67,7 +51,7 @@ class ProductAttributeTermSchema extends TermSchema {
 	private function get_visual_property_schema(): array {
 		return array(
 			'description' => __( 'Experimental visual swatch data for wc-visual attribute terms.', 'woocommerce' ),
-			'type'        => array( 'object', 'null' ),
+			'type'        => 'object',
 			'context'     => array( 'view', 'edit' ),
 			'readonly'    => true,
 			'properties'  => array(
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductAttributeTerms.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductAttributeTerms.php
index 2e98bcee218..24a93092b24 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductAttributeTerms.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductAttributeTerms.php
@@ -81,6 +81,7 @@ class ProductAttributeTerms extends ControllerTestCase {
 		$this->assertArrayHasKey( 'slug', $data[0] );
 		$this->assertArrayHasKey( 'description', $data[0] );
 		$this->assertArrayHasKey( 'count', $data[0] );
+		$this->assertArrayNotHasKey( '__experimentalVisual', $data[0] );
 	}

 	/**
@@ -97,6 +98,7 @@ class ProductAttributeTerms extends ControllerTestCase {
 		$this->assertEquals( 'small-slug', $data['slug'] );
 		$this->assertEquals( 'Description of small', $data['description'] );
 		$this->assertEquals( 0, $data['count'] );
+		$this->assertArrayNotHasKey( '__experimentalVisual', $data );
 	}

 	/**
@@ -110,6 +112,7 @@ class ProductAttributeTerms extends ControllerTestCase {
 		$this->assertArrayHasKey( 'order', $params );
 		$this->assertArrayHasKey( 'orderby', $params );
 		$this->assertArrayHasKey( 'hide_empty', $params );
+		$this->assertArrayHasKey( '__experimental_visual', $params );
 	}

 	/**
@@ -119,12 +122,17 @@ class ProductAttributeTerms extends ControllerTestCase {
 		$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
 		$controller = $routes->get( 'product-attribute-terms' );
 		$schema     = $controller->get_item_schema();
-		$response   = $controller->prepare_item_for_response( get_term_by( 'name', 'small', 'pa_size' ), new \WP_REST_Request() );
-		$data       = $response->get_data();
-		$validate   = new ValidateSchema( $schema );
+		$request    = new \WP_REST_Request();
+		$request->set_param( '__experimental_visual', true );
+		$response = $controller->prepare_item_for_response( get_term_by( 'name', 'red', 'pa_color' ), $request );
+		$data     = $response->get_data();
+		$validate = new ValidateSchema( $schema );

 		$this->assertArrayHasKey( '__experimentalVisual', $data );
-		$this->assertNull( $data['__experimentalVisual'] );
+		$this->assertSame( 'none', $data['__experimentalVisual']['type'] );
+		$this->assertSame( '', $data['__experimentalVisual']['value'] );
+
+		$data['__experimentalVisual'] = (object) $data['__experimentalVisual'];

 		$diff = $validate->get_diff_from_object( $data );
 		$this->assertEmpty( $diff, print_r( $diff, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
@@ -141,7 +149,10 @@ class ProductAttributeTerms extends ControllerTestCase {
 		$term = get_term_by( 'name', 'red', 'pa_color' );
 		update_term_meta( $term->term_id, 'color', '#00ff00' );

-		$response = $controller->prepare_item_for_response( $term, new \WP_REST_Request() );
+		$request = new \WP_REST_Request();
+		$request->set_param( '__experimental_visual', true );
+
+		$response = $controller->prepare_item_for_response( $term, $request );
 		$data     = $response->get_data();

 		$validate = new ValidateSchema( $schema );