Commit 3eb83a8a2a9 for woocommerce

commit 3eb83a8a2a9b304f4b9448c8f66b47684f81b560
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Fri Oct 9 14:10:40 2026 +0200

    Revert apostrophe encoding in classic checkout requests (#69644)

    This reverts commit 975cd3b352bc4773ffe3fc85b5c68c4e68ec1fdf (#68365).

    It restores the 11.1 request shape. update_order_review, apply_coupon
    and remove_coupon pass a data object to $.ajax() again, so fields
    removed with checkout field filters are dropped instead of being sent
    as empty values that blank the customer's country, state and postcode.
    Third-party ajaxPrefilter callbacks get the data object again rather
    than a pre-serialized string. Place order sends $form.serialize() as
    before.

    The #68365 tests are replaced with tests for the restored request
    shape. The apostrophe encoding will be re-landed separately.

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-revert-checkout-apostrophe-encoding b/plugins/woocommerce/changelog/fix-revert-checkout-apostrophe-encoding
new file mode 100644
index 00000000000..110c7b68a06
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-revert-checkout-apostrophe-encoding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore how the classic checkout sends order review and coupon requests, so fields removed with checkout field filters are no longer sent as empty values.
diff --git a/plugins/woocommerce/client/legacy/js/frontend/checkout.js b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
index 2c6959a888d..96b8ebee1fc 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/checkout.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
@@ -5,19 +5,6 @@ jQuery( function ( $ ) {
 		return false;
 	}

-	/**
-	 * Percent-encode literal apostrophes in an already URL-encoded request body.
-	 *
-	 * `encodeURIComponent()` leaves `'` alone, so serialized bodies can reach the
-	 * server with literal apostrophes that some WAF rules reject.
-	 *
-	 * @param {string} data URL-encoded request body.
-	 * @return {string} Body with apostrophes encoded as %27.
-	 */
-	function encodeApostrophes( data ) {
-		return data.split( "'" ).join( '%27' );
-	}
-
 	$.blockUI.defaults.overlayCSS.cursor = 'default';

 	// A paste can carry characters that render as nothing. The server strips them
@@ -752,7 +739,7 @@ jQuery( function ( $ ) {
 				url: wc_checkout_params.wc_ajax_url
 					.toString()
 					.replace( '%%endpoint%%', 'update_order_review' ),
-				data: encodeApostrophes( $.param( data ) ),
+				data: data,
 				success: function ( data ) {
 					// Reload the page if requested
 					if ( data && true === data.reload ) {
@@ -1030,7 +1017,7 @@ jQuery( function ( $ ) {
 				$.ajax( {
 					type: 'POST',
 					url: wc_checkout_params.checkout_url,
-					data: encodeApostrophes( $form.serialize() ),
+					data: $form.serialize(),
 					dataType: 'json',
 					success: function ( result ) {
 						// Detach the unload handler that prevents a reload / redirect
@@ -1336,7 +1323,7 @@ jQuery( function ( $ ) {
 				url: wc_checkout_params.wc_ajax_url
 					.toString()
 					.replace( '%%endpoint%%', 'apply_coupon' ),
-				data: encodeApostrophes( $.param( data ) ),
+				data: data,
 				success: function ( response ) {
 					$(
 						'.woocommerce-error, .woocommerce-message, .is-error, .is-success, .checkout-inline-error-message'
@@ -1410,7 +1397,7 @@ jQuery( function ( $ ) {
 				url: wc_checkout_params.wc_ajax_url
 					.toString()
 					.replace( '%%endpoint%%', 'remove_coupon' ),
-				data: encodeApostrophes( $.param( data ) ),
+				data: data,
 				success: function ( code ) {
 					$(
 						'.woocommerce-error, .woocommerce-message, .is-error, .is-success'
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
index 0af5fd4cbac..e88fe4571d3 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
@@ -2,10 +2,8 @@
  * @jest-environment jest-fixed-jsdom
  */

-// Apostrophe-bearing coupon fixtures. `billing_email` is the field from the
-// original report, and the coupon endpoints post it from the checkout page.
-const COUPON_CODE = "SAVE'10";
-const BILLING_EMAIL = "o'brien@example.com";
+const COUPON_CODE = 'SAVE10';
+const BILLING_EMAIL = 'shopper@example.com';

 describe( 'createCheckoutPlaceOrderApi', () => {
 	let $allNotices;
@@ -37,11 +35,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 		capturedApi = null;
 		capturedAjaxRequests = [];
 		serializedCheckoutData =
-			"billing_email=shopper'o%40example.test" +
-			'&company=Rock+%26+Roll' +
-			'&items%5B%5D=one' +
-			"&delivery'note=Recipient's+door" +
-			'&reference=already%27encoded';
+			'billing_email=shopper%40example.com&company=Rock+%26+Roll';
 		let hiddenInvalidCount = 0;
 		setHiddenInvalidCount = ( count ) => {
 			hiddenInvalidCount = count;
@@ -268,15 +262,12 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			entry.handler.call( element, { preventDefault: jest.fn() } );
 		};

-		// update_order_review sends these as siblings of post_data, so they have
-		// to carry apostrophes for the test to prove the whole body is encoded.
+		// update_order_review reads these as siblings of post_data. Country and
+		// state are left out, as if removed with woocommerce_billing_fields.
 		const addressFieldValues = {
-			'#billing_country': 'US',
-			'#billing_state': "O'State",
 			':input#billing_postcode': '12345',
-			'#billing_city': "O'Fallon",
-			':input#billing_address_1': "123 O'Brien Ave",
-			':input#billing_address_2': "Apt O'2",
+			'#billing_city': 'Springfield',
+			':input#billing_address_1': '123 Main St',
 		};

 		// The coupon form is bound directly at init(), so it needs a stable mock
@@ -375,31 +366,6 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			capturedAjaxRequests.push( options );
 			return { abort: jest.fn() };
 		} );
-		jQueryMock.param = jest.fn( ( object ) => {
-			const parts = [];
-			const add = ( key, value ) => {
-				parts.push(
-					encodeURIComponent( key ) +
-						'=' +
-						encodeURIComponent(
-							value === null || value === undefined ? '' : value
-						)
-				);
-			};
-			const buildParams = ( prefix, value ) => {
-				if ( value !== null && typeof value === 'object' ) {
-					Object.keys( value ).forEach( ( key ) =>
-						buildParams( prefix + '[' + key + ']', value[ key ] )
-					);
-					return;
-				}
-				add( prefix, value );
-			};
-			Object.keys( object ).forEach( ( key ) =>
-				buildParams( key, object[ key ] )
-			);
-			return parts.join( '&' ).split( '%20' ).join( '+' );
-		} );
 		jQueryMock.ajaxSetup = jest.fn();
 		jQueryMock.isEmptyObject = jest.fn( ( value ) => {
 			return Object.keys( value ).length === 0;
@@ -530,7 +496,9 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 		} );
 	} );

-	describe( 'Checkout form serialization', () => {
+	// Request bodies keep the 11.1 shape: data objects for the AJAX endpoints, so
+	// $.ajax() drops undefined (removed) fields and prefilters see an object.
+	describe( 'Checkout request data', () => {
 		beforeEach( () => {
 			jest.useFakeTimers();
 		} );
@@ -540,14 +508,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			jest.useRealTimers();
 		} );

-		const expectedSerializedData =
-			'billing_email=shopper%27o%40example.test' +
-			'&company=Rock+%26+Roll' +
-			'&items%5B%5D=one' +
-			'&delivery%27note=Recipient%27s+door' +
-			'&reference=already%27encoded';
-
-		test( 'should encode apostrophes in update order review data', () => {
+		test( 'should send update order review data as an object', () => {
 			mockBody.trigger( 'update_checkout', [
 				{ update_shipping_method: false },
 			] );
@@ -558,42 +519,34 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			);

 			expect( request ).toBeDefined();
-			expect( request.data ).not.toContain( "'" );
-
-			// Address fields travel outside post_data and must be encoded too.
-			expect( request.data ).toContain( 'city=O%27Fallon' );
-			expect( request.data ).toContain( 'address=123+O%27Brien+Ave' );
-			expect( request.data ).toContain( 'state=O%27State' );
-
-			// The whole body is encoded, so post_data survives the outer layer
-			// byte-for-byte and raw-string consumers see what serialize() produced.
-			const postData = new URLSearchParams( request.data ).get(
-				'post_data'
+			expect( typeof request.data ).toBe( 'object' );
+			expect( request.data ).toEqual(
+				expect.objectContaining( {
+					city: 'Springfield',
+					post_data: serializedCheckoutData,
+				} )
 			);
-			expect( postData ).toBe( serializedCheckoutData );
+			expect( request.data.country ).toBeUndefined();
+			expect( request.data.state ).toBeUndefined();
 		} );

-		test( 'should encode apostrophes in final checkout data', () => {
+		test( 'should send the serialized form when placing an order', () => {
 			const submitRegistration = $form.on.mock.calls.find(
 				( call ) => call[ 0 ] === 'submit'
 			);
 			expect( submitRegistration ).toBeDefined();

 			submitRegistration[ 1 ].call( $form );
+
 			const request = capturedAjaxRequests.find(
 				( options ) => options.url === '/?wc-ajax=checkout'
 			);

 			expect( request ).toBeDefined();
-			expect( request.data ).toBe( expectedSerializedData );
+			expect( request.data ).toBe( serializedCheckoutData );
 		} );
-	} );

-	// The coupon endpoints are the fourth and third of the four request bodies
-	// routed through encodeApostrophes(). No fake timers here: neither handler
-	// schedules one unless its success callback runs, which these never do.
-	describe( 'Coupon request serialization', () => {
-		test( 'should encode apostrophes in apply coupon data', () => {
+		test( 'should send apply coupon data as an object', () => {
 			const submitRegistration = $couponForm.on.mock.calls.find(
 				( call ) => call[ 0 ] === 'submit'
 			);
@@ -606,14 +559,15 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			);

 			expect( request ).toBeDefined();
-			expect( request.data ).not.toContain( "'" );
-
-			const body = new URLSearchParams( request.data );
-			expect( body.get( 'coupon_code' ) ).toBe( COUPON_CODE );
-			expect( body.get( 'billing_email' ) ).toBe( BILLING_EMAIL );
+			expect( request.data ).toEqual(
+				expect.objectContaining( {
+					coupon_code: COUPON_CODE,
+					billing_email: BILLING_EMAIL,
+				} )
+			);
 		} );

-		test( 'should encode apostrophes in remove coupon data', () => {
+		test( 'should send remove coupon data as an object', () => {
 			triggerDelegatedBodyEvent(
 				'click',
 				'.woocommerce-remove-coupon',
@@ -625,12 +579,12 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			);

 			expect( request ).toBeDefined();
-			expect( request.data ).not.toContain( "'" );
-			expect( new URLSearchParams( request.data ).get( 'coupon' ) ).toBe(
-				COUPON_CODE
+			expect( request.data ).toEqual(
+				expect.objectContaining( { coupon: COUPON_CODE } )
 			);
 		} );
 	} );
+
 	// update_order_review keeps `result` as the legacy "a notice was rendered" signal, so
 	// notices are rendered for every result and only `has_errors` clears the existing ones,
 	// revalidates the form fields, and scrolls. Responses without the flag fall back to `result`.