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`.