Commit 7d78a2ef342 for woocommerce

commit 7d78a2ef342b274db838e7009f3b28a7bc79ae89
Author: Francesco <frosso@users.noreply.github.com>
Date:   Wed Sep 9 17:15:05 2026 +0200

    fix: stop order item_data reporting a hidden meta row's id (#68390)

diff --git a/docs/apis/store-api/resources-endpoints/order.md b/docs/apis/store-api/resources-endpoints/order.md
index 797c4f73b75..3dd8fc483a4 100644
--- a/docs/apis/store-api/resources-endpoints/order.md
+++ b/docs/apis/store-api/resources-endpoints/order.md
@@ -239,7 +239,7 @@ Each order item carries its metadata in `item_data`, a list of entries:

 | Property | Type | Description |
 | --- | --- | --- |
-| `id` | integer \| null | The order item metadata row ID. `null` when an extension added the entry through `woocommerce_order_item_get_formatted_meta_data` without a stored row behind it. |
+| `id` | integer \| null | The order item metadata row ID. `null` when no displayed metadata row backs the entry, which is the case for anything an extension added through `woocommerce_order_item_get_formatted_meta_data`. |
 | `key` | string | Metadata key. |
 | `value` | string | Metadata value. |
 | `display_key` | string | Key, formatted for display. |
diff --git a/plugins/woocommerce/changelog/fix-store-api-order-item-data-hidden-meta-id b/plugins/woocommerce/changelog/fix-store-api-order-item-data-hidden-meta-id
new file mode 100644
index 00000000000..4a2f3cf73ad
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-order-item-data-hidden-meta-id
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: the order endpoint no longer reports a hidden metadata row's ID on an item_data entry an extension appended. That entry now correctly reports a null `id`.
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
index 270dbe13a55..62d96adc0db 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
@@ -52,7 +52,7 @@ class OrderItemSchema extends ItemSchema {
 				'type'       => 'object',
 				'properties' => [
 					'id'            => [
-						'description' => __( 'Order item metadata ID. Null for entries an extension added that have no stored metadata row.', 'woocommerce' ),
+						'description' => __( 'Order item metadata ID. Null for an entry that no displayed metadata row backs, such as one an extension added.', 'woocommerce' ),
 						'type'        => [ 'integer', 'null' ],
 						'context'     => [ 'view', 'edit' ],
 						'readonly'    => true,
@@ -97,8 +97,30 @@ class OrderItemSchema extends ItemSchema {
 	 * @return array
 	 */
 	private function get_item_data( $order_item ) {
+		$item_data = [];
+
+		// A callback appending with `$formatted_meta[] =` gets PHP's next integer key, not a row ID.
+		// Mirror what `get_formatted_meta_data()` skips for this call's `_` prefix and `$include_all`:
+		// a row it leaves out never keys an entry, so counting its ID lets that key land on a hidden
+		// row saved right after the visible ones. Read rows first; formatting rewrites keys and values.
+		$meta_row_ids = [];
+
+		/**
+		 * `get_meta_data()` is documented as returning bare objects.
+		 *
+		 * @var \WC_Meta_Data[] $meta_rows
+		 */
+		$meta_rows = $order_item->get_meta_data();
+
+		foreach ( $meta_rows as $meta ) {
+			if ( empty( $meta->id ) || '' === $meta->value || ! is_scalar( $meta->value ) || 0 === strpos( (string) $meta->key, '_' ) ) {
+				continue;
+			}
+
+			$meta_row_ids[ $meta->id ] = true;
+		}
+
 		$formatted_meta_data = $order_item->get_all_formatted_meta_data();
-		$item_data           = [];

 		// A `woocommerce_order_item_get_formatted_meta_data` callback can return anything, and a bad
 		// one must not take the endpoint down.
@@ -106,10 +128,6 @@ class OrderItemSchema extends ItemSchema {
 			return $item_data;
 		}

-		// A callback appending with `$formatted_meta[] =` gets PHP's next integer key, not a row ID.
-		// Same source the formatted metadata is built from, so matching costs no query.
-		$meta_row_ids = array_flip( array_filter( wp_list_pluck( $order_item->get_meta_data(), 'id' ) ) );
-
 		foreach ( $formatted_meta_data as $meta_id => $meta ) {
 			// Only public fields are meant to ship. Casting an object reaches past them and
 			// publishes mangled names, so let it serialize itself first, then read what is public.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
index 803faeb7153..9bf790f3fb1 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
@@ -343,6 +343,63 @@ class Order extends ControllerTestCase {
 		$this->assertNull( $item_data[1]['id'], 'An entry with no stored row has no ID to report.' );
 	}

+	/**
+	 * `get_formatted_meta_data()` skips hidden, empty and non-scalar rows, so an appended entry's
+	 * next-int key can be one of their IDs: a skipped row saved right after the item's visible ones
+	 * sits on exactly that number. On a store that happens when an extension saves a hidden or blank
+	 * field beside a displayed one, and when stock reduction writes `_reduced_stock` after payment.
+	 *
+	 * @testWith ["_reduced_stock", 1]
+	 *           ["Empty note", ""]
+	 *           ["Options", {"size": "L"}]
+	 *
+	 * @testdox Order item_data does not report a skipped meta row's ID for an appended entry.
+	 *
+	 * @param string $skipped_key   A metadata key the formatter leaves out.
+	 * @param mixed  $skipped_value The value stored against it.
+	 */
+	public function test_item_data_ignores_skipped_meta_rows_when_matching_ids( string $skipped_key, $skipped_value ): void {
+		$order = $this->create_guest_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'Gift message', 'Happy birthday', true );
+		$item->add_meta_data( $skipped_key, $skipped_value, true );
+		$item->save();
+
+		$row_ids = array_values( wp_list_pluck( $item->get_meta_data(), 'id' ) );
+
+		$this->assertSame(
+			$row_ids[0] + 1,
+			$row_ids[1],
+			'The skipped row must follow the visible one for this to exercise the collision.'
+		);
+
+		add_filter(
+			'woocommerce_order_item_get_formatted_meta_data',
+			function ( $formatted_meta ) {
+				$formatted_meta[] = (object) array(
+					'key'           => 'Appended',
+					'value'         => 'value',
+					'display_key'   => 'Appended',
+					'display_value' => 'value',
+				);
+				return $formatted_meta;
+			}
+		);
+
+		wp_set_current_user( 0 );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+		$request->set_param( 'key', $order->get_order_key() );
+		$request->set_param( 'billing_email', $order->get_billing_email() );
+
+		$item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+		$this->assertCount( 2, $item_data, 'The skipped row must not become an entry of its own.' );
+		$this->assertSame( $row_ids[0], $item_data[0]['id'] );
+		$this->assertSame( 'Appended', $item_data[1]['key'] );
+		$this->assertNull( $item_data[1]['id'], 'An entry with no stored row has no ID to report.' );
+	}
+
 	/**
 	 * `WC_Meta_Data` keeps its fields protected, so casting one publishes mangled property names.
 	 * Trunk serialized it through `JsonSerializable`, and so must this.