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.