Commit 6d9a30af993 for woocommerce

commit 6d9a30af99300792961a3761b8b7e854a961b14e
Author: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Date:   Mon Sep 14 14:46:33 2026 +0300

    Support array values for structured data types (#68459)

    * test: cover structured data scalar type filtering

    WC_Structured_Data lowercases scalar @type values before filtering them for output. This high-traffic path had no direct regression coverage.

    Pin the public output for a scalar Product type before adding support for array types.

    Refs #30964

    * fix: support array values for structured data types

    Schema.org permits multiple @type values, but WC_Structured_Data passes arrays to string-only PHP functions. On PHP 8 this prevents valid markup from being emitted.

    Validate each array member, retain valid arrays verbatim, then group them using the first type requested by the current output context. Keep scalar behavior unchanged.

    Refs #30964

    * fix: complete structured data array type support

    WC_Structured_Data now accepts array-valued @type values, but non-list inputs encode as objects while co-typed nodes can ignore caller priority.

    Normalize array keys, honor requested type order, document the widened contract, plus cover the affected output paths.

    Refs #30964

    * test: cover structured data type validation boundaries

    WC_Structured_Data now applies its long-standing type-name validation to the newly accepted array shape.

    The array tests did not protect the minimum and maximum lengths, and the widened public contract did not describe that constraint.

    Cover 1-, 20-, and 21-character values through set_data() and document the accepted ASCII-letter range.

    Refs #30964

    * test: cover structured data context filter contract

    * test: strengthen structured data type guard coverage

    ---------

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

diff --git a/plugins/woocommerce/changelog/30964-support-array-structured-data-types b/plugins/woocommerce/changelog/30964-support-array-structured-data-types
new file mode 100644
index 00000000000..6b805770a9e
--- /dev/null
+++ b/plugins/woocommerce/changelog/30964-support-array-structured-data-types
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Allow structured data filters to return multiple schema types without causing a fatal error or dropping valid product markup.
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index b5aa9644667..aa70108c3a2 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -50,12 +50,28 @@ class WC_Structured_Data {
 	/**
 	 * Sets data.
 	 *
-	 * @param  array $data  Structured data.
+	 * @since 3.0.0
+	 * @since 11.2.0 Added support for multiple schema types in `@type`.
+	 *
+	 * @param  array $data  Structured data. The `@type` value accepts a string or an array of strings.
+	 *                      Every type must contain 1 to 20 ASCII letters; any invalid array member rejects the node.
 	 * @param  bool  $reset Unset data (default: false).
 	 * @return bool
 	 */
 	public function set_data( $data, $reset = false ) {
-		if ( ! isset( $data['@type'] ) || ! preg_match( '|^[a-zA-Z]{1,20}$|', $data['@type'] ) ) {
+		if ( isset( $data['@type'] ) && is_array( $data['@type'] ) ) {
+			if ( empty( $data['@type'] ) ) {
+				return false;
+			}
+
+			foreach ( $data['@type'] as $type ) {
+				if ( ! is_string( $type ) || ! preg_match( '|^[a-zA-Z]{1,20}$|', $type ) ) {
+					return false;
+				}
+			}
+
+			$data['@type'] = array_values( $data['@type'] );
+		} elseif ( ! isset( $data['@type'] ) || ! preg_match( '|^[a-zA-Z]{1,20}$|', $data['@type'] ) ) {
 			return false;
 		}

@@ -96,7 +112,15 @@ class WC_Structured_Data {

 		// Put together the values of same type of structured data.
 		foreach ( $this->get_data() as $value ) {
-			$data[ strtolower( $value['@type'] ) ][] = $value;
+			if ( is_array( $value['@type'] ) ) {
+				$value_types    = array_map( 'strtolower', $value['@type'] );
+				$matching_types = array_intersect( $types, $value_types );
+				$type           = $matching_types ? reset( $matching_types ) : reset( $value_types );
+
+				$data[ $type ][] = $value;
+			} else {
+				$data[ strtolower( $value['@type'] ) ][] = $value;
+			}
 		}

 		// Wrap the multiple values of each type inside a graph... Then add context to each type.
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
index 29ef21a802d..aced43c79b7 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
@@ -20,6 +20,217 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 		parent::setUp();
 	}

+	/**
+	 * @testdox get_structured_data() preserves scalar @type grouping.
+	 */
+	public function test_get_structured_data_preserves_scalar_type_grouping(): void {
+		$markup = array(
+			'@type' => 'Product',
+			'name'  => 'Test product',
+		);
+		$this->structured_data->set_data( $markup );
+
+		$this->assertSame(
+			array(
+				'@context' => 'https://schema.org/',
+				'@type'    => 'Product',
+				'name'     => 'Test product',
+			),
+			$this->structured_data->get_structured_data( array( 'product' ) ),
+			'Scalar structured data should retain its existing grouping and output.'
+		);
+	}
+
+	/**
+	 * @testdox set_data() accepts a valid list of @type values.
+	 *
+	 * @testWith [["Car", "Product"]]
+	 *           [["A"]]
+	 *           [["ABCDEFGHIJKLMNOPQRST"]]
+	 *
+	 * @param array $types Structured data types.
+	 */
+	public function test_set_data_accepts_valid_array_type( array $types ): void {
+		$markup = array(
+			'@type' => $types,
+			'name'  => 'Test product',
+		);
+
+		$this->assertTrue(
+			$this->structured_data->set_data( $markup ),
+			'A valid array @type should be accepted.'
+		);
+		$this->assertSame(
+			array( $markup ),
+			$this->structured_data->get_data(),
+			'A valid list of @type values should retain its values and order.'
+		);
+	}
+
+	/**
+	 * @testdox set_data() normalizes a non-list @type array before storage.
+	 */
+	public function test_set_data_normalizes_non_list_array_type(): void {
+		$markup = array(
+			'@type' => array(
+				0 => 'Product',
+				2 => 'Book',
+			),
+			'name'  => 'Test product',
+		);
+
+		$this->assertTrue(
+			$this->structured_data->set_data( $markup ),
+			'A valid non-list @type array should be accepted.'
+		);
+		$this->assertSame(
+			array(
+				array(
+					'@type' => array( 'Product', 'Book' ),
+					'name'  => 'Test product',
+				),
+			),
+			$this->structured_data->get_data(),
+			'A non-list @type array should be reindexed so it encodes as a JSON list.'
+		);
+	}
+
+	/**
+	 * @testdox set_data() rejects an invalid array @type.
+	 *
+	 * @testWith [[]]
+	 *           [["Product", ["Review"]]]
+	 *           [["Product", "Invalid-Type"]]
+	 *           [["Product", "ABCDEFGHIJKLMNOPQRSTU"]]
+	 *
+	 * @param array $types Structured data types.
+	 */
+	public function test_set_data_rejects_invalid_array_type( array $types ): void {
+		$this->assertFalse(
+			$this->structured_data->set_data( array( '@type' => $types ) ),
+			'Empty arrays, non-string members, and pattern-invalid members should be rejected.'
+		);
+	}
+
+	/**
+	 * @testdox get_structured_data() emits array @type values for matching or unfiltered requests.
+	 *
+	 * @testWith [["Car", "Product"], ["product"]]
+	 *           [["Thing", "CustomType"], ["customtype"]]
+	 *           [["Car", "Product"], []]
+	 *
+	 * @param string[] $schema_types    Schema types stored in the markup.
+	 * @param string[] $requested_types Lower-case output types requested by the caller.
+	 */
+	public function test_get_structured_data_outputs_array_type( array $schema_types, array $requested_types ): void {
+		$markup = array(
+			'@type' => $schema_types,
+			'name'  => 'Test product',
+		);
+		$this->structured_data->set_data( $markup );
+
+		$this->assertSame(
+			array(
+				'@context' => 'https://schema.org/',
+				'@type'    => $schema_types,
+				'name'     => 'Test product',
+			),
+			$this->structured_data->get_structured_data( $requested_types ),
+			'Array @type markup should survive grouping and page-type filtering.'
+		);
+	}
+
+	/**
+	 * @testdox get_structured_data() groups multi-type nodes by requested type priority.
+	 */
+	public function test_get_structured_data_groups_array_type_by_requested_priority(): void {
+		$this->structured_data->set_data(
+			array(
+				'@type' => 'Product',
+				'name'  => 'Scalar product',
+			)
+		);
+		$this->structured_data->set_data(
+			array(
+				'@type' => array( 'Review', 'Product' ),
+				'name'  => 'Multi-type product',
+			)
+		);
+
+		$this->assertSame(
+			array(
+				'@context' => 'https://schema.org/',
+				'@graph'   => array(
+					array(
+						'@type' => 'Product',
+						'name'  => 'Scalar product',
+					),
+					array(
+						'@type' => array( 'Review', 'Product' ),
+						'name'  => 'Multi-type product',
+					),
+				),
+			),
+			$this->structured_data->get_structured_data( array( 'product', 'review' ) ),
+			'Multi-type nodes should join the group selected by the requested type order.'
+		);
+	}
+
+	/**
+	 * @testdox woocommerce_structured_data_context receives the string grouping key and complete @type array.
+	 */
+	public function test_structured_data_context_receives_array_type_and_string_grouping_key(): void {
+		$markup           = array(
+			'@type' => array( 'Review', 'Product' ),
+			'name'  => 'Multi-type product',
+		);
+		$filter_arguments = array();
+
+		add_filter(
+			'woocommerce_structured_data_context',
+			static function ( $context, $data, $type, $value ) use ( &$filter_arguments ) {
+				$filter_arguments = array(
+					'type'  => $type,
+					'value' => $value,
+				);
+
+				return $context;
+			},
+			10,
+			4
+		);
+
+		$this->structured_data->set_data( $markup );
+		$this->structured_data->get_structured_data( array( 'product', 'review' ) );
+
+		$this->assertSame(
+			array(
+				'type'  => 'product',
+				'value' => array( $markup ),
+			),
+			$filter_arguments,
+			'The context filter should receive the selected string grouping key and the unmodified multi-type node.'
+		);
+	}
+
+	/**
+	 * @testdox get_structured_data() excludes multi-type nodes without a requested type.
+	 */
+	public function test_get_structured_data_excludes_array_type_without_requested_match(): void {
+		$this->structured_data->set_data(
+			array(
+				'@type' => array( 'Thing', 'CreativeWork' ),
+				'name'  => 'Unrequested node',
+			)
+		);
+
+		$this->assertSame(
+			array(),
+			$this->structured_data->get_structured_data( array( 'product' ) ),
+			'Multi-type nodes without a requested type should be excluded.'
+		);
+	}
+
 	/**
 	 * @testdox Order structured data looks up the product image with an integer attachment ID.
 	 */