Commit 2762e009350 for woocommerce

commit 2762e0093509f92e501f0aec31460a06fbb33bfb
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Wed Sep 23 10:45:19 2026 +0800

    Stop URL-encoding array event properties twice in the analytics pixel (#68907)

    * Fix array event properties being URL-encoded twice on the pixel URL

    * Add changelog entry for array property double encoding fix

diff --git a/packages/php/woocommerce-analytics/changelog/fix-wooa7s-2110-array-prop-encoding b/packages/php/woocommerce-analytics/changelog/fix-wooa7s-2110-array-prop-encoding
new file mode 100644
index 00000000000..d8f741cdddc
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-wooa7s-2110-array-prop-encoding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop URL-encoding array event properties twice so Tracks stores the plain comma-joined value.
diff --git a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
index 390dc29bb6c..48a303b0253 100644
--- a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
+++ b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
@@ -700,8 +700,9 @@ class WC_Analytics_Tracking {
 			return '';
 		}

+		// Not URL-encoded here: http_build_query() in Pixel_Builder encodes the whole URL, so encoding twice stores `%2F` in Tracks.
 		if ( array_keys( $value ) === range( 0, count( $value ) - 1 ) ) {
-			return rawurlencode( implode( ',', $value ) );
+			return implode( ',', $value );
 		}

 		return wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Array_Props_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Array_Props_Test.php
new file mode 100644
index 00000000000..1ec3fbbdc3e
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Array_Props_Test.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Tests for how array-valued event properties reach the pixel URL.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use WorDBless\BaseTestCase;
+
+/**
+ * Array props must arrive at Tracks as one comma-joined value under their own key.
+ *
+ * Two regressions are guarded here: the bracketed keys (`prop[0]`) that
+ * `http_build_query()` produces for a raw array, which Tracks rejects, and the
+ * double URL-encoding that left `%2F` in the stored value once decoded.
+ */
+class WC_Analytics_Tracking_Array_Props_Test extends BaseTestCase {
+
+	/**
+	 * Build the pixel URL's raw query string for the given event properties.
+	 *
+	 * @param array $event_properties Properties handed to `get_properties()`.
+	 * @return string The query string exactly as it leaves `http_build_query()`.
+	 */
+	private function raw_pixel_query( array $event_properties ): string {
+		$props = WC_Analytics_Tracking::get_properties( 'woocommerceanalytics_add_to_cart', $event_properties );
+		$url   = Pixel_Builder::build_tracks_url( $props );
+
+		$this->assertIsString( $url, 'The pixel URL must build for array props.' );
+
+		return (string) wp_parse_url( $url, PHP_URL_QUERY );
+	}
+
+	/**
+	 * Decode the pixel URL once, the way the Tracks endpoint does.
+	 *
+	 * @param array $event_properties Properties handed to `get_properties()`.
+	 * @return array Query parameters as Tracks would read them.
+	 */
+	private function decoded_pixel_query( array $event_properties ): array {
+		parse_str( $this->raw_pixel_query( $event_properties ), $query );
+
+		return $query;
+	}
+
+	/**
+	 * Indexed arrays reach Tracks as a plain comma-joined string.
+	 */
+	public function test_indexed_array_prop_decodes_once_to_the_joined_value(): void {
+		$query = $this->decoded_pixel_query(
+			array(
+				'additional_blocks_on_cart_page' => array( 'woocommerce/cart-cross-sells-block', 'core/paragraph' ),
+			)
+		);
+
+		$this->assertSame(
+			'woocommerce/cart-cross-sells-block,core/paragraph',
+			$query['additional_blocks_on_cart_page'],
+			'A single decode must yield the block names, not a second layer of %2F and %2C.'
+		);
+	}
+
+	/**
+	 * No key in the pixel URL carries the brackets that make Tracks reject the event.
+	 */
+	public function test_array_props_never_produce_bracketed_keys(): void {
+		$event_properties = array(
+			'additional_blocks_on_cart_page'     => array( 'core/paragraph', 'core/group' ),
+			'additional_blocks_on_checkout_page' => array(),
+		);
+
+		// Checked on the raw query string: parse_str() would fold `prop[0]` back into a nested array and hide the brackets.
+		$raw = $this->raw_pixel_query( $event_properties );
+		$this->assertDoesNotMatchRegularExpression( '/(^|&)[^=&]*(%5B|%5D|\[|\])[^=&]*=/i', $raw );
+
+		$query = $this->decoded_pixel_query( $event_properties );
+		$this->assertSame( '', $query['additional_blocks_on_checkout_page'], 'An empty array is an empty value under the original key.' );
+	}
+
+	/**
+	 * Associative arrays keep their JSON form, still decoding once to valid JSON.
+	 */
+	public function test_associative_array_prop_decodes_once_to_json(): void {
+		$query = $this->decoded_pixel_query( array( 'meta' => array( 'a' => 1, 'b' => 'x/y' ) ) );
+
+		$this->assertSame( array( 'a' => 1, 'b' => 'x/y' ), json_decode( $query['meta'], true ) );
+	}
+}
diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
index 7a816c90166..09762b2d232 100644
--- a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
@@ -517,7 +517,7 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 		$props     = WC_Analytics_Tracking::get_properties( 'woocommerceanalytics_product_view', $sanitized, true );

 		$this->assertSame(
-			rawurlencode( implode( ',', array_fill( 0, WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS, 'a' ) ) ),
+			implode( ',', array_fill( 0, WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS, 'a' ) ),
 			$props['pc']
 		);
 	}