Commit 059adef26e5 for woocommerce
commit 059adef26e50a2102ce9a380db3a52e01abcf227
Author: Wesley Rosa <wesleyjrosa@gmail.com>
Date: Thu Oct 22 10:32:11 2026 -0300
[Mise en Place] Fix percent escapes stripped from posted custom attribute values (#68483)
* Fix percent escapes stripped from posted custom attribute values
* Add changelog entry for percent escape attribute fix
* Remove resolved html_entity_decode entries from PHPStan baseline
* Decode entities before stripping tags in attribute sanitization
* Fix percent escapes when ordering variations again
* Update changelog for percent escapes when ordering again
* Prevent order item meta formatting from mutating stored metadata
Clone each WC_Meta_Data before rawurldecode in get_formatted_meta_data so
display and email formatting can no longer alter the value saved with the
order item. This keeps percent escapes such as Black%20White intact through
status transitions, fixing the reorder path that previously reconstructed
BlackWhite. Strengthen the reorder regression to add the item before the
completed transition so it exercises the formatting path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjyG1m53SdYXJk3gpQUr5M
* Drop stale cart-session html_entity_decode PHPStan baseline entry
The percent-escape sanitization fix resolved the html_entity_decode
argument.type error in class-wc-cart-session.php, so its baseline entry no
longer matches and fails PHPStan. Remove the now-obsolete entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjyG1m53SdYXJk3gpQUr5M
* Decode custom variation values in Store API cart display
The Blocks cart showed the raw stored value (Black%20White) while the classic
cart and variation titles decode it (Black White) via wc_get_formatted_variation.
Decode the value in format_variation_data so both carts display consistently.
Cart storage still preserves the literal percent escape; only the display value
changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjyG1m53SdYXJk3gpQUr5M
* Limit Store API variation decode to custom values
The display decode added for percent-escaped custom values also decoded
taxonomy term slugs, so an unresolved slug like small-%f0%9f%a4%8f was
returned as small-🤏, breaking test_add_variable_product_to_cart_with_attribute_data.
Move rawurldecode into the non-taxonomy branch so only custom values decode;
taxonomy slugs are returned unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjyG1m53SdYXJk3gpQUr5M
* Drop stale order-item undefined-property PHPStan baseline entries
Typing the metadata objects as WC_Meta_Data[] in get_formatted_meta_data
resolved the "Access to an undefined property object::$key/$value" errors, so
their baseline entries no longer match and fail the full PHPStan run. Remove
the two obsolete entries. Verified against the complete analysis, not a
single-file run, since the unmatched-baseline check only fires project-wide.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjyG1m53SdYXJk3gpQUr5M
* Decode custom variation option labels on the single product page
The variation dropdown showed the raw stored value (Black%20White) while the
cart, checkout and order now display it decoded (Black White). Decode the
option label in wc_dropdown_variation_attribute_options so the product page
reads consistently; the option value attribute stays raw so the posted value
still matches. Add a regression test asserting the value stays raw and the
label is decoded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjyG1m53SdYXJk3gpQUr5M
* Recover decoded custom attribute values when reordering
populate_cart_from_order() matched the stored order-item attribute value
against the product's options with a strict comparison via
meta_is_product_attribute(). When display formatting had decoded the value
(Black%20White shown as Black White), it no longer matched the product's raw
option, so the reordered item kept its variation ID and price but lost the
attribute (the cart showed the base product name). Add a decoded-form
fallback that resolves the stored value back to the product's canonical
option, so the rebuilt variation validates. Add a regression test for the
decoded-value case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjyG1m53SdYXJk3gpQUr5M
* Revert "Recover decoded custom attribute values when reordering"
This reverts commit ad8ea7f0930e0380594140fa2eb57c93000fe0be.
* Keep raw item meta in the admin order edit fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGeaPVoLWg7KcTE1w7xAWd
* Fix lint findings in the order item meta view test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGeaPVoLWg7KcTE1w7xAWd
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-wooplug-7648-percent-escape-custom-attributes b/plugins/woocommerce/changelog/fix-wooplug-7648-percent-escape-custom-attributes
new file mode 100644
index 00000000000..d26d5abb6e5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7648-percent-escape-custom-attributes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve percent sequences in custom variation attribute values when adding to the cart or ordering again, so options like Black%20White validate instead of being rejected.
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item-meta.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item-meta.php
index 3ddd681b46c..eb3a0a54dd9 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item-meta.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item-meta.php
@@ -3,7 +3,7 @@
* Shows an order item meta
*
* @package WooCommerce\Admin
- * @var object $item The item being displayed
+ * @var WC_Order_Item $item The item being displayed
* @var int $item_id The id of the item being displayed
*/
if ( ! defined( 'ABSPATH' ) ) {
@@ -36,15 +36,25 @@ $hidden_order_itemmeta = \Automattic\WooCommerce\Internal\Utilities\OrderItemMet
<tbody class="meta_items">
<?php if ( $meta_data ) : ?>
<?php
+ // The edit fields are saved back as they are, so they must hold the stored key and value,
+ // not the URL-decoded copies made for display.
+ $raw_meta_data = array();
+ foreach ( $item->get_meta_data() as $raw_meta ) {
+ $raw_meta_data[ $raw_meta->id ] = $raw_meta;
+ }
+
foreach ( $meta_data as $meta_id => $meta ) :
if ( in_array( $meta->key, $hidden_order_itemmeta, true ) ) {
continue;
}
+
+ $meta_key = isset( $raw_meta_data[ $meta_id ] ) ? $raw_meta_data[ $meta_id ]->key : $meta->key;
+ $meta_value = isset( $raw_meta_data[ $meta_id ] ) ? $raw_meta_data[ $meta_id ]->value : $meta->value;
?>
<tr data-meta_id="<?php echo esc_attr( $meta_id ); ?>">
<td>
- <input type="text" maxlength="255" placeholder="<?php esc_attr_e( 'Name (required)', 'woocommerce' ); ?>" name="meta_key[<?php echo esc_attr( $item_id ); ?>][<?php echo esc_attr( $meta_id ); ?>]" value="<?php echo esc_attr( $meta->key ); ?>" />
- <textarea placeholder="<?php esc_attr_e( 'Value (required)', 'woocommerce' ); ?>" name="meta_value[<?php echo esc_attr( $item_id ); ?>][<?php echo esc_attr( $meta_id ); ?>]"><?php echo esc_textarea( rawurldecode( $meta->value ) ); ?></textarea>
+ <input type="text" maxlength="255" placeholder="<?php esc_attr_e( 'Name (required)', 'woocommerce' ); ?>" name="meta_key[<?php echo esc_attr( $item_id ); ?>][<?php echo esc_attr( $meta_id ); ?>]" value="<?php echo esc_attr( $meta_key ); ?>" />
+ <textarea placeholder="<?php esc_attr_e( 'Value (required)', 'woocommerce' ); ?>" name="meta_value[<?php echo esc_attr( $item_id ); ?>][<?php echo esc_attr( $meta_id ); ?>]"><?php echo esc_textarea( (string) $meta_value ); ?></textarea>
</td>
<td width="1%"><button class="remove_order_item_meta button">×</button></td>
</tr>
diff --git a/plugins/woocommerce/includes/class-wc-cart-session.php b/plugins/woocommerce/includes/class-wc-cart-session.php
index 0dc416f6c62..df2ba47ea56 100644
--- a/plugins/woocommerce/includes/class-wc-cart-session.php
+++ b/plugins/woocommerce/includes/class-wc-cart-session.php
@@ -680,7 +680,8 @@ final class WC_Cart_Session {
if ( taxonomy_is_product_attribute( $meta->key ) ) {
$variations[ $attribute_key ] = sanitize_title( $meta->value );
} else {
- $variations[ $attribute_key ] = html_entity_decode( wc_clean( $meta->value ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+ // Preserve literal percent escapes, matching custom values added through the cart and Store API.
+ $variations[ $attribute_key ] = wp_strip_all_tags( html_entity_decode( $meta->value, ENT_QUOTES, get_bloginfo( 'charset' ) ) );
}
}
}
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index 05ef116cb17..cdfdaf08b08 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -1249,7 +1249,8 @@ class WC_Cart extends WC_Legacy_Cart {
// Don't use wc_clean as it destroys sanitized characters.
$value = sanitize_title( wp_unslash( $variation[ $attribute_key ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
} else {
- $value = html_entity_decode( wc_clean( wp_unslash( $variation[ $attribute_key ] ) ), ENT_QUOTES, get_bloginfo( 'charset' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ // Don't use wc_clean as it strips percent sequences (e.g. `%20`) that can legitimately appear in custom attribute values, making them fail validation against the stored options. Entities are decoded before tag stripping so encoded markup cannot survive as literal tags.
+ $value = wp_strip_all_tags( html_entity_decode( wp_unslash( $variation[ $attribute_key ] ), ENT_QUOTES, get_bloginfo( 'charset' ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
// Don't include if it's empty.
diff --git a/plugins/woocommerce/includes/class-wc-order-item.php b/plugins/woocommerce/includes/class-wc-order-item.php
index af8b91ad6c4..67abe640e04 100644
--- a/plugins/woocommerce/includes/class-wc-order-item.php
+++ b/plugins/woocommerce/includes/class-wc-order-item.php
@@ -333,8 +333,13 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
* @return array
*/
public function get_formatted_meta_data( $hideprefix = '_', $include_all = false ) {
- $formatted_meta = array();
+ /**
+ * Metadata objects owned by the order item.
+ *
+ * @var WC_Meta_Data[] $meta_data
+ */
$meta_data = $this->get_meta_data();
+ $formatted_meta = array();
$hideprefix_length = ! empty( $hideprefix ) ? strlen( $hideprefix ) : 0;
$product = is_callable( array( $this, 'get_product' ) ) ? $this->get_product() : false;
$order_item_name = $this->get_name();
@@ -348,6 +353,8 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
continue;
}
+ // Decode a copy so display formatting cannot change metadata saved with the order item.
+ $meta = clone $meta;
$meta->key = rawurldecode( (string) $meta->key );
$meta->value = rawurldecode( (string) $meta->value );
$attribute_key = str_replace( 'attribute_', '', $meta->key );
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index 4892f72b244..6efe81fff82 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -3816,7 +3816,21 @@ if ( ! function_exists( 'wc_dropdown_variation_attribute_options' ) ) {
foreach ( $options as $option ) {
// This handles < 2.4.0 bw compatibility where text attributes were not sanitized.
$selected = sanitize_title( $args['selected'] ) === $args['selected'] ? selected( $args['selected'], sanitize_title( $option ), false ) : selected( $args['selected'], $option, false );
- $html .= '<option value="' . esc_attr( $option ) . '" ' . $selected . '>' . esc_html( apply_filters( 'woocommerce_variation_option_name', $option, null, $attribute, $product ) ) . '</option>';
+
+ /**
+ * Filter the variation option name displayed for a custom (non-taxonomy) attribute.
+ *
+ * @since 2.5.0
+ * @param string $option_name Option name to display.
+ * @param null $term Null because this is not a taxonomy attribute.
+ * @param string $attribute Attribute name.
+ * @param WC_Product $product Product object.
+ */
+ $option_name = apply_filters( 'woocommerce_variation_option_name', $option, null, $attribute, $product );
+
+ // Custom option values can be stored URL-encoded; decode the label so it reads the same
+ // as the cart and order, while the value attribute stays raw so the posted value matches.
+ $html .= '<option value="' . esc_attr( $option ) . '" ' . $selected . '>' . esc_html( rawurldecode( (string) $option_name ) ) . '</option>';
}
}
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index b38361c492a..c3b055fb149 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -5718,12 +5718,6 @@ parameters:
count: 1
path: includes/admin/meta-boxes/views/html-order-fee.php
- -
- message: '#^Call to an undefined method object\:\:get_all_formatted_meta_data\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: includes/admin/meta-boxes/views/html-order-item-meta.php
-
-
message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#'
identifier: argument.type
@@ -9630,12 +9624,6 @@ parameters:
count: 1
path: includes/class-wc-cart-session.php
- -
- message: '#^Parameter \#1 \$string of function html_entity_decode expects string, array\|string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/class-wc-cart-session.php
-
-
message: '#^Parameter \#2 \$value of function wc_setcookie expects string, int given\.$#'
identifier: argument.type
@@ -10134,12 +10122,6 @@ parameters:
count: 3
path: includes/class-wc-cart.php
- -
- message: '#^Parameter \#1 \$string of function html_entity_decode expects string, array\|string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/class-wc-cart.php
-
-
message: '#^Parameter \#1 \$value of function wc_round_tax_total expects float, string given\.$#'
identifier: argument.type
@@ -12582,18 +12564,6 @@ parameters:
count: 1
path: includes/class-wc-order-item-tax.php
- -
- message: '#^Access to an undefined property object\:\:\$key\.$#'
- identifier: property.notFound
- count: 3
- path: includes/class-wc-order-item.php
-
- -
- message: '#^Access to an undefined property object\:\:\$value\.$#'
- identifier: property.notFound
- count: 1
- path: includes/class-wc-order-item.php
-
-
message: '#^Argument of an invalid type array\<WC_Meta_Data\>\|null supplied for foreach, only iterables are supported\.$#'
identifier: foreach.nonIterable
@@ -67374,12 +67344,6 @@ parameters:
count: 4
path: src/StoreApi/Utilities/CartController.php
- -
- message: '#^Parameter \#1 \$string of function html_entity_decode expects string, array\|string given\.$#'
- identifier: argument.type
- count: 3
- path: src/StoreApi/Utilities/CartController.php
-
-
message: '#^Parameter \#1 \$text of function esc_html expects string, int\|string given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index 1181f14e12c..cf449024d4e 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -1402,6 +1402,7 @@ class CartController {
* Format and sanitize variation data posted to the API.
*
* Labels are converted to names (e.g. Size to pa_size), and values are cleaned.
+ * Custom (non-taxonomy) values are stripped of HTML tags but keep percent sequences (e.g. `%20`), which `wc_clean()` would destroy, breaking validation against the stored options.
*
* @throws RouteException Exception if variation cannot be found.
*
@@ -1425,10 +1426,12 @@ class CartController {
?
sanitize_title( $variation_data[ $variation_attribute_name ] )
:
- html_entity_decode(
- wc_clean( $variation_data[ $variation_attribute_name ] ),
- ENT_QUOTES,
- get_bloginfo( 'charset' )
+ wp_strip_all_tags(
+ html_entity_decode(
+ $variation_data[ $variation_attribute_name ],
+ ENT_QUOTES,
+ get_bloginfo( 'charset' )
+ )
);
continue;
}
@@ -1446,10 +1449,12 @@ class CartController {
?
sanitize_title( $variation_data[ $attribute_label ] )
:
- html_entity_decode(
- wc_clean( $variation_data[ $attribute_label ] ),
- ENT_QUOTES,
- get_bloginfo( 'charset' )
+ wp_strip_all_tags(
+ html_entity_decode(
+ $variation_data[ $attribute_label ],
+ ENT_QUOTES,
+ get_bloginfo( 'charset' )
+ )
);
continue;
}
@@ -1461,10 +1466,12 @@ class CartController {
?
sanitize_title( $variation_data[ $attribute['name'] ] )
:
- html_entity_decode(
- wc_clean( $variation_data[ $attribute['name'] ] ),
- ENT_QUOTES,
- get_bloginfo( 'charset' )
+ wp_strip_all_tags(
+ html_entity_decode(
+ $variation_data[ $attribute['name'] ],
+ ENT_QUOTES,
+ get_bloginfo( 'charset' )
+ )
);
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductItemTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductItemTrait.php
index dab46a59793..6bbdeda69a6 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductItemTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductItemTrait.php
@@ -87,6 +87,11 @@ trait ProductItemTrait {
*/
$value = apply_filters( 'woocommerce_variation_option_name', $value, null, $taxonomy, $product );
$label = wc_attribute_label( str_replace( 'attribute_', '', $key ), $product );
+
+ // Custom values can be stored URL-encoded. Decode for display so the Blocks
+ // cart shows the same value as the classic cart and variation titles, which
+ // decode via wc_get_formatted_variation(). Taxonomy slugs are left as-is.
+ $value = rawurldecode( (string) $value );
}
$return[] = array(
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-item-meta-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-item-meta-test.php
new file mode 100644
index 00000000000..e5aa7a98142
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-item-meta-test.php
@@ -0,0 +1,108 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Tests for the order item meta rows rendered inside the order items meta box.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+class Html_Order_Item_Meta_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Render the meta rows for an order item.
+ *
+ * @param WC_Order_Item $item The order item to render.
+ * @return string The rendered markup.
+ */
+ private function render_item_meta( WC_Order_Item $item ): string {
+ $item_id = $item->get_id();
+
+ ob_start();
+ include WC_ABSPATH . 'includes/admin/meta-boxes/views/html-order-item-meta.php';
+
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Collect the meta fields the order form posts when the merchant clicks Update.
+ *
+ * @param string $markup Rendered meta rows.
+ * @param int $item_id Order item ID.
+ * @return array<string, array<int, array<int, string>>>
+ */
+ private function get_posted_meta_fields( string $markup, int $item_id ): array {
+ $document = new DOMDocument();
+ $document->loadHTML( '<?xml encoding="utf-8"?>' . $markup, LIBXML_NOERROR );
+ $xpath = new DOMXPath( $document );
+ $fields = array(
+ 'order_item_id' => array( $item_id ),
+ 'meta_key' => array(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Admin form field name, not a query.
+ 'meta_value' => array(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin form field name, not a query.
+ );
+
+ foreach ( $xpath->query( '//input[starts-with(@name, "meta_key[")]' ) as $input ) {
+ preg_match( '/\[(\d+)\]\[(\d+)\]/', $input->getAttribute( 'name' ), $ids );
+ $fields['meta_key'][ (int) $ids[1] ][ (int) $ids[2] ] = $input->getAttribute( 'value' );
+ }
+
+ foreach ( $xpath->query( '//textarea[starts-with(@name, "meta_value[")]' ) as $textarea ) {
+ preg_match( '/\[(\d+)\]\[(\d+)\]/', $textarea->getAttribute( 'name' ), $ids );
+ $fields['meta_value'][ (int) $ids[1] ][ (int) $ids[2] ] = $textarea->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMNode property.
+ }
+
+ return $fields;
+ }
+
+ /**
+ * @testdox Updating an order in the admin keeps percent escapes in custom attribute values, so Order again restores the variation.
+ */
+ public function test_admin_update_preserves_percent_escapes_for_order_again(): void {
+ require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php';
+
+ $user_id = $this->factory->user->create();
+ wp_set_current_user( $user_id );
+
+ $product = new WC_Product_Variable();
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Finish' );
+ $attribute->set_options( array( 'Black%20White', 'Gloss' ) );
+ $attribute->set_variation( true );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'finish' => 'Black%20White' ) );
+ $variation->set_regular_price( '10' );
+ $variation->save();
+
+ $order = wc_create_order( array( 'customer_id' => $user_id ) );
+ $order->add_product( $variation, 1, array( 'variation' => array( 'finish' => 'Black%20White' ) ) );
+ $order->save();
+
+ $items = $order->get_items();
+ $item = reset( $items );
+ $markup = $this->render_item_meta( $item );
+
+ $this->assertStringContainsString( 'Black White', $markup, 'The read-only view should show the decoded value.' );
+
+ wc_save_order_items( $order->get_id(), $this->get_posted_meta_fields( $markup, $item->get_id() ) );
+
+ $order = wc_get_order( $order->get_id() );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $items = $order->get_items();
+ $this->assertSame( 'Black%20White', reset( $items )->get_meta( 'finish' ), 'Saving the admin form must not decode the stored value.' );
+
+ $sut = new WC_Cart_Session( WC()->cart );
+ $method = new ReflectionMethod( WC_Cart_Session::class, 'populate_cart_from_order' );
+ $method->setAccessible( true );
+ $cart = $method->invoke( $sut, $order->get_id(), array() );
+
+ $this->assertCount( 1, $cart, 'Order again should restore the ordered variation.' );
+ $this->assertSame( 'Black%20White', reset( $cart )['variation']['attribute_finish'], 'Order again must keep the selected custom value.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index b9c07477923..af77d9ef44b 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -778,6 +778,95 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
$variable_product->delete( true );
}
+ /**
+ * @testdox Completing an order and ordering again preserves percent escapes and the selected variation.
+ * @testWith ["Black%20White"]
+ * [""]
+ * @param string $variation_value Fixed value or an Any variation.
+ */
+ public function test_order_again_preserves_custom_attribute_percent_escapes( string $variation_value ): void {
+ $user_id = $this->factory->user->create();
+ wp_set_current_user( $user_id );
+ WC()->cart->empty_cart();
+
+ $product = new WC_Product_Variable();
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Finish' );
+ $attribute->set_options( array( 'Black%20White', 'Gloss' ) );
+ $attribute->set_variation( true );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'finish' => $variation_value ) );
+ $variation->set_regular_price( '10' );
+ $variation->save();
+
+ $this->assertContains( 'Black%20White', wc_get_product( $product->get_id() )->get_variation_attributes()['Finish'], 'The ordered option must be available on the product.' );
+
+ $order = wc_create_order( array( 'customer_id' => $user_id ) );
+ $order->add_product( $variation, 1, array( 'variation' => array( 'finish' => 'Black%20White' ) ) );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $order_items = wc_get_order( $order->get_id() )->get_items();
+ $order_item = reset( $order_items );
+ $this->assertSame( 'Black%20White', $order_item->get_meta( 'finish' ), 'Stored order metadata should contain the literal selected value.' );
+
+ $sut = new WC_Cart_Session( WC()->cart );
+ $method = new ReflectionMethod( WC_Cart_Session::class, 'populate_cart_from_order' );
+ $method->setAccessible( true );
+ $cart = $method->invoke( $sut, $order->get_id(), array() );
+
+ $this->assertCount( 1, $cart, 'The ordered variation should be restored.' );
+ $item = reset( $cart );
+ $this->assertSame( $variation->get_id(), $item['variation_id'], 'The variation ID must survive reordering.' );
+ $this->assertSame( 'Black%20White', $item['variation']['attribute_finish'], 'Reordering must preserve the selected custom value.' );
+ }
+
+ /**
+ * @testdox Custom attribute values containing a percent escape can be added to the cart.
+ */
+ public function test_add_to_cart_accepts_custom_attribute_values_containing_percent_escapes() {
+ WC()->cart->empty_cart();
+ WC()->session->set( 'wc_notices', null );
+
+ $product = new WC_Product_Variable();
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Finish' );
+ $attribute->set_options( array( 'Black%20White', 'Gloss' ) );
+ $attribute->set_visible( true );
+ $attribute->set_variation( true );
+ $product->set_name( 'Percent Escape Product' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $fixed_variation = new WC_Product_Variation();
+ $fixed_variation->set_parent_id( $product->get_id() );
+ $fixed_variation->set_attributes( array( 'finish' => 'Black%20White' ) );
+ $fixed_variation->set_regular_price( '10' );
+ $fixed_variation->save();
+
+ $cart_item_key = WC()->cart->add_to_cart( $product->get_id(), 1, $fixed_variation->get_id(), array( 'attribute_finish' => 'Black%20White' ) );
+
+ $this->assertNotFalse( $cart_item_key, 'A posted value matching the stored option must pass validation.' );
+ $this->assertSame( 'Black%20White', WC()->cart->get_cart_item( $cart_item_key )['variation']['attribute_finish'], 'The percent escape must survive sanitization unaltered.' );
+
+ WC()->cart->empty_cart();
+
+ $any_variation = new WC_Product_Variation();
+ $any_variation->set_parent_id( $product->get_id() );
+ $any_variation->set_attributes( array( 'finish' => '' ) );
+ $any_variation->set_regular_price( '10' );
+ $any_variation->save();
+
+ $any_cart_item_key = WC()->cart->add_to_cart( $product->get_id(), 1, $any_variation->get_id(), array( 'attribute_finish' => 'Black%20White' ) );
+
+ $this->assertNotFalse( $any_cart_item_key, 'A posted value for an Any attribute must validate against the configured options.' );
+ $this->assertSame( 'Black%20White', WC()->cart->get_cart_item( $any_cart_item_key )['variation']['attribute_finish'], 'The percent escape must survive sanitization on the Any path too.' );
+ }
+
/**
* @testdox should throw a notice to the cart if using an invalid product_id.
*/
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php b/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
index 42c6eb5f989..de1722093b6 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
@@ -64,6 +64,76 @@ class WC_Order_Item_Product_Test extends WC_Unit_Test_Case {
$this->disable_cogs_feature();
}
+ /**
+ * @testdox Formatting metadata preserves raw keys and values across repeated calls and saves.
+ * @testWith ["Finish%20type", "Black%2520White", "Finish type", "Black%20White"]
+ * ["finish", "Black%20White", "finish", "Black White"]
+ * ["count", 123, "count", "123"]
+ * @param string $raw_key Original metadata key.
+ * @param string|int $raw_value Original metadata value.
+ * @param string $display_key Decoded metadata key.
+ * @param string $display_value Decoded metadata value.
+ */
+ public function test_formatting_preserves_raw_metadata( string $raw_key, $raw_value, string $display_key, string $display_value ): void {
+ $sut = $this->item;
+ $sut->add_meta_data( $raw_key, $raw_value, true );
+ $sut->save();
+ $raw_meta = $sut->get_meta( $raw_key, false )[0];
+
+ $first = $sut->get_formatted_meta_data();
+ $second = $sut->get_formatted_meta_data();
+
+ $this->assertArrayHasKey( $raw_meta->id, $first, 'The saved metadata should be formatted.' );
+ $this->assertSame( $display_key, $first[ $raw_meta->id ]->key, 'Formatted keys should remain URL-decoded.' );
+ $this->assertSame( $display_value, $first[ $raw_meta->id ]->value, 'Formatted values should remain URL-decoded.' );
+ $this->assertSame( $first[ $raw_meta->id ]->value, $second[ $raw_meta->id ]->value, 'Repeated formatting must not decode the value again.' );
+ $this->assertSame( $raw_key, $raw_meta->key, 'Formatting must not change the live metadata key.' );
+ $this->assertSame( $raw_value, $raw_meta->value, 'Formatting must not change the live metadata value or type.' );
+
+ $sut->save();
+ $reloaded = new WC_Order_Item_Product( $sut->get_id() );
+ $this->assertSame( (string) $raw_value, $reloaded->get_meta( $raw_key ), 'Saving after formatting must preserve the original metadata in the database.' );
+ }
+
+ /**
+ * @testdox Display filters receive decoded metadata without changing the order item.
+ */
+ public function test_formatting_filters_receive_decoded_metadata(): void {
+ $sut = $this->item;
+ $sut->add_meta_data( 'Finish%20type', 'Black%20White', true );
+ $sut->save();
+ $raw_meta = $sut->get_meta( 'Finish%20type', false )[0];
+ $received = array();
+
+ foreach ( array( 'woocommerce_order_item_display_meta_key', 'woocommerce_order_item_display_meta_value' ) as $hook ) {
+ add_filter(
+ $hook,
+ static function ( $display, $meta, $item ) use ( &$received, $hook ) {
+ $received[ $hook ] = array( $display, $meta, $item );
+ return $display;
+ },
+ 10,
+ 3
+ );
+ }
+
+ $formatted = $sut->get_formatted_meta_data();
+
+ $this->assertCount( 2, $received, 'Both display filters must still run.' );
+ foreach ( $received as $arguments ) {
+ $this->assertInstanceOf( WC_Meta_Data::class, $arguments[1], 'Display filters must still receive WC_Meta_Data.' );
+ $this->assertSame( $raw_meta->id, $arguments[1]->id, 'The metadata ID must be preserved.' );
+ $this->assertSame( 'Finish type', $arguments[1]->key, 'Filters must receive the decoded key.' );
+ $this->assertSame( 'Black White', $arguments[1]->value, 'Filters must receive the decoded value.' );
+ $this->assertSame( $sut, $arguments[2], 'Filters must receive the original order item.' );
+ }
+ $this->assertSame( 'Finish type', $received['woocommerce_order_item_display_meta_key'][0] );
+ $this->assertSame( 'Black White', $received['woocommerce_order_item_display_meta_value'][0] );
+ $this->assertSame( "<p>Black White</p>\n", $formatted[ $raw_meta->id ]->display_value, 'Rendered output should remain unchanged.' );
+ $this->assertSame( 'Finish%20type', $raw_meta->key, 'Display filters must not be given the live metadata object.' );
+ $this->assertSame( 'Black%20White', $raw_meta->value, 'The order item must retain its original value.' );
+ }
+
/**
* Test that backorder meta is excluded from formatted meta data
* for completed orders.
diff --git a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
index e673bcaae89..788f2e0f0de 100644
--- a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
@@ -358,6 +358,36 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
$this->assertStringContainsString( 'Matte', $markup );
}
+ /**
+ * @testdox wc_dropdown_variation_attribute_options keeps a percent-escaped custom option value raw and decodes its label.
+ */
+ public function test_variation_dropdown_decodes_custom_option_label_but_keeps_value_raw(): void {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Finish' );
+ $attribute->set_options( array( 'Black%20White', 'Gloss' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ ob_start();
+ wc_dropdown_variation_attribute_options(
+ array(
+ 'options' => array( 'Black%20White', 'Gloss' ),
+ 'attribute' => 'Finish',
+ 'product' => $product,
+ 'selected' => 'Black%20White',
+ 'name' => 'attribute_finish',
+ )
+ );
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'value="Black%20White"', $html, 'The option value must stay raw so the posted value still matches.' );
+ $this->assertStringContainsString( '>Black White<', $html, 'The option label must be decoded to match the cart and order.' );
+ $this->assertStringNotContainsString( '>Black%20White<', $html, 'The percent-escaped label must not be displayed.' );
+ }
+
/**
* @testdox Displaying product attributes renders plain text when a term link cannot be resolved.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
index 7d33239f648..683e1054f6b 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
@@ -850,6 +850,72 @@ class Cart extends ControllerTestCase {
$this->assertStringContainsString( 'no-store', $server->sent_headers['Cache-Control'] );
}
+ /**
+ * @testdox Adding custom variations preserves percent escapes in cart storage and decodes them in the API display value.
+ * @testWith ["attribute_finish", "Black%20White"]
+ * ["Finish", "Black%20White"]
+ * ["finish", "Black%20White"]
+ * ["attribute_finish", ""]
+ * ["Finish", ""]
+ * ["finish", ""]
+ * ["Finish", "Black%20White", true]
+ * ["Finish", "", true]
+ * @param string $attribute_name Posted attribute name.
+ * @param string $variation_value Fixed value or an Any variation.
+ * @param bool $filter_label Whether the display label differs from the stored name.
+ */
+ public function test_add_item_preserves_custom_attribute_percent_escapes( string $attribute_name, string $variation_value, bool $filter_label = false ): void {
+ wc_empty_cart();
+
+ if ( $filter_label ) {
+ add_filter(
+ 'woocommerce_attribute_label',
+ static function () {
+ return 'Surface';
+ }
+ );
+ }
+
+ $product = new \WC_Product_Variable();
+ $attribute = new \WC_Product_Attribute();
+ $attribute->set_name( 'Finish' );
+ $attribute->set_options( array( 'Black%20White', 'Gloss' ) );
+ $attribute->set_variation( true );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'finish' => $variation_value ) );
+ $variation->set_regular_price( '10' );
+ $variation->save();
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'id' => $variation->get_id(),
+ 'quantity' => 1,
+ 'variation' => array(
+ array(
+ 'attribute' => $attribute_name,
+ 'value' => 'Black%20White',
+ ),
+ ),
+ )
+ );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 201, $response->get_status(), 'A matching custom attribute must be accepted.' );
+ $data = $response->get_data();
+ $this->assertCount( 1, $data['items'], 'The response should contain the added variation.' );
+ $this->assertSame( 'Black White', $data['items'][0]['variation'][0]['value'], 'The display value must be decoded so Blocks matches the classic cart.' );
+ $cart = WC()->cart->get_cart();
+ $item = reset( $cart );
+ $this->assertSame( $variation->get_id(), $item['variation_id'], 'The cart must contain the selected variation.' );
+ $this->assertSame( 'Black%20White', $item['variation']['attribute_finish'], 'The cart must preserve the selected custom value.' );
+ }
+
/**
* Test adding a variable product to cart returns proper variation data.
*/