Commit 434650beaba for woocommerce

commit 434650beaba1a2c594c12a1262b9b139551f8df2
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Sep 2 11:17:18 2026 +0300

    Fix CES submit label normalization (#68222)

    * fix: Normalize CES submit labels

    CES action creators, reducers, persisted queues, and components used three different submit-label spellings. The add-survey path consequently dropped custom success labels when its emitted field did not match the reducer contract.

    Normalize historical aliases at store boundaries, keep canonical state for first-party consumers, preserve selector-visible legacy queue keys, and migrate the live PHP inline caller. This retains compatibility with existing dispatch callers and mixed-version persisted data.

    Refs #39052

    * chore: Add CES submit label changelogs

    Record the submit-label normalization for both the public customer-effort-score package and the WooCommerce plugin caller so each affected package receives the correct patch release note.

    Refs #39052

diff --git a/packages/js/customer-effort-score/changelog/fix-39052-normalize-submit-label b/packages/js/customer-effort-score/changelog/fix-39052-normalize-submit-label
new file mode 100644
index 00000000000..30d4ef52833
--- /dev/null
+++ b/packages/js/customer-effort-score/changelog/fix-39052-normalize-submit-label
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Normalize customer effort score submit labels while preserving legacy action and queue aliases.
diff --git a/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/index.js b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/index.js
index b50076e350e..bf6bc22e00b 100644
--- a/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/index.js
+++ b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/index.js
@@ -4,7 +4,6 @@
 import { useEffect } from 'react';
 import { compose } from '@wordpress/compose';
 import { withDispatch, withSelect } from '@wordpress/data';
-import { createElement, Fragment } from '@wordpress/element';
 import { optionsStore } from '@woocommerce/data';

 /**
@@ -61,7 +60,7 @@ function _CustomerEffortScoreTracksContainer( {
 					secondQuestion={ item.secondQuestion }
 					icon={ item.icon }
 					title={ item.title }
-					onSubmitLabel={ item.onsubmit_label }
+					onSubmitLabel={ item.onSubmitLabel }
 					trackProps={ item.props || {} }
 				/>
 			) ) }
diff --git a/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/test/index.js b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/test/index.js
new file mode 100644
index 00000000000..f21fe2fce8d
--- /dev/null
+++ b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/test/index.js
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { createElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { CustomerEffortScoreTracksContainer } from '..';
+
+jest.mock( '@wordpress/compose', () => ( {
+	compose: () => ( Component ) => Component,
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+	withDispatch: jest.fn(),
+	withSelect: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/data', () => ( {
+	optionsStore: 'wc/admin/options',
+} ) );
+
+jest.mock( '../../../store', () => ( {
+	QUEUE_OPTION_NAME: 'woocommerce_ces_tracks_queue',
+	STORE_KEY: 'wc/customer-effort-score',
+} ) );
+
+jest.mock( '../..', () => {
+	const { createElement: mockCreateElement } =
+		jest.requireActual( '@wordpress/element' );
+
+	return {
+		CustomerEffortScoreTracks: ( { onSubmitLabel } ) =>
+			mockCreateElement( 'span', null, onSubmitLabel ),
+	};
+} );
+
+describe( 'CustomerEffortScoreTracksContainer', () => {
+	const originalPagenow = window.pagenow;
+	const originalAdminpage = window.adminpage;
+
+	beforeEach( () => {
+		window.pagenow = 'product';
+		window.adminpage = 'post-php';
+	} );
+
+	afterEach( () => {
+		window.pagenow = originalPagenow;
+		window.adminpage = originalAdminpage;
+	} );
+
+	it( 'forwards the canonical label from a normalized queue item', () => {
+		const clearQueue = jest.fn();
+
+		render(
+			createElement( CustomerEffortScoreTracksContainer, {
+				queue: [
+					{
+						onSubmitLabel: 'Canonical success',
+						onsubmit_label: 'Legacy value',
+						pagenow: 'product',
+						adminpage: 'post-php',
+					},
+				],
+				resolving: false,
+				clearQueue,
+			} )
+		);
+
+		expect( screen.getByText( 'Canonical success' ) ).toBeInTheDocument();
+		expect( screen.queryByText( 'Legacy value' ) ).not.toBeInTheDocument();
+		expect( clearQueue ).toHaveBeenCalledTimes( 1 );
+	} );
+} );
diff --git a/packages/js/customer-effort-score/src/store/actions.js b/packages/js/customer-effort-score/src/store/actions.js
index 0ab0f78dfa6..0da045a758a 100644
--- a/packages/js/customer-effort-score/src/store/actions.js
+++ b/packages/js/customer-effort-score/src/store/actions.js
@@ -7,6 +7,7 @@ import { __ } from '@wordpress/i18n';
  * Internal dependencies
  */
 import TYPES from './action-types';
+import { getOnSubmitLabel } from './get-on-submit-label';

 /**
  * Initialize the state
@@ -23,18 +24,20 @@ export function setCesSurveyQueue( queue ) {
 /**
  * Add a new CES track to the state.
  *
- * @param {Object} args                 All arguments.
- * @param {string} args.action          action name for the survey
- * @param {string} args.title           title for the snackback
- * @param {string} args.description     description for feedback modal.
- * @param {string} args.noticeLabel     noticeLabel for notice.
- * @param {string} args.firstQuestion   first question for modal survey
- * @param {string} args.secondQuestion  second question for modal survey
- * @param {string} [args.icon]          optional icon for notice.
- * @param {string} [args.pageNow]       optional value of window.pagenow, default to window.pagenow
- * @param {string} [args.adminPage]     optional value of window.adminpage, default to window.adminpage
- * @param {string} [args.onsubmitLabel] optional label for the snackback onsubmit, default to undefined
- * @param {Object} args.props           object for optional props
+ * @param {Object} args                  All arguments.
+ * @param {string} args.action           action name for the survey
+ * @param {string} args.title            title for the snackback
+ * @param {string} args.description      description for feedback modal.
+ * @param {string} args.noticeLabel      noticeLabel for notice.
+ * @param {string} args.firstQuestion    first question for modal survey
+ * @param {string} args.secondQuestion   second question for modal survey
+ * @param {string} [args.icon]           optional icon for notice.
+ * @param {string} [args.pageNow]        optional value of window.pagenow, default to window.pagenow
+ * @param {string} [args.adminPage]      optional value of window.adminpage, default to window.adminpage
+ * @param {string} [args.onSubmitLabel]  optional label for the snackback onsubmit, default to undefined
+ * @param {string} [args.onsubmitLabel]  deprecated lower-camel alias for onSubmitLabel
+ * @param {string} [args.onsubmit_label] deprecated snake-case alias for onSubmitLabel
+ * @param {Object} args.props            object for optional props
  */
 export function addCesSurvey( {
 	action,
@@ -46,7 +49,9 @@ export function addCesSurvey( {
 	icon,
 	pageNow = window.pagenow,
 	adminPage = window.adminpage,
-	onsubmitLabel = undefined,
+	onSubmitLabel,
+	onsubmitLabel,
+	onsubmit_label,
 	props = {},
 } ) {
 	return {
@@ -60,7 +65,11 @@ export function addCesSurvey( {
 		icon,
 		pageNow,
 		adminPage,
-		onsubmit_label: onsubmitLabel,
+		onSubmitLabel: getOnSubmitLabel( {
+			onSubmitLabel,
+			onsubmitLabel,
+			onsubmit_label,
+		} ),
 		props,
 	};
 }
@@ -81,7 +90,7 @@ export function showCesModal(
 	return {
 		type: TYPES.SHOW_CES_MODAL,
 		surveyProps,
-		onsubmit_label: surveyProps.onsubmitLabel || '',
+		onSubmitLabel: getOnSubmitLabel( surveyProps ) ?? '',
 		props,
 		onSubmitNoticeProps,
 		tracksProps,
@@ -137,7 +146,6 @@ export function addCesSurveyForCustomerSearch() {
 		),
 		pageNow: 'woocommerce_page_wc-admin',
 		adminPage: 'woocommerce_page_wc-admin',
-		onsubmit_label: undefined,
 		props: {
 			search_area: 'customer',
 		},
diff --git a/packages/js/customer-effort-score/src/store/get-on-submit-label.js b/packages/js/customer-effort-score/src/store/get-on-submit-label.js
new file mode 100644
index 00000000000..9dc263d0413
--- /dev/null
+++ b/packages/js/customer-effort-score/src/store/get-on-submit-label.js
@@ -0,0 +1,2 @@
+export const getOnSubmitLabel = ( value = {} ) =>
+	value.onSubmitLabel ?? value.onsubmitLabel ?? value.onsubmit_label;
diff --git a/packages/js/customer-effort-score/src/store/reducer.js b/packages/js/customer-effort-score/src/store/reducer.js
index 99f3570fd2b..301d974da2a 100644
--- a/packages/js/customer-effort-score/src/store/reducer.js
+++ b/packages/js/customer-effort-score/src/store/reducer.js
@@ -2,6 +2,7 @@
  * Internal dependencies
  */
 import TYPES from './action-types';
+import { getOnSubmitLabel } from './get-on-submit-label';

 const DEFAULT_STATE = {
 	queue: [],
@@ -15,7 +16,13 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
 		case TYPES.SET_CES_SURVEY_QUEUE:
 			return {
 				...state,
-				queue: [ ...state.queue, ...action.queue ],
+				queue: [
+					...state.queue,
+					...action.queue.map( ( item ) => ( {
+						...item,
+						onSubmitLabel: getOnSubmitLabel( item ),
+					} ) ),
+				],
 			};
 		case TYPES.HIDE_CES_MODAL:
 			return {
@@ -29,7 +36,7 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
 				description: action.surveyProps.description,
 				showDescription: action.surveyProps.showDescription,
 				title: action.surveyProps.title,
-				onSubmitLabel: action.onsubmit_label,
+				onSubmitLabel: getOnSubmitLabel( action ),
 				firstQuestion: action.surveyProps.firstQuestion,
 				secondQuestion: action.surveyProps.secondQuestion,
 				onSubmitNoticeProps: action.onSubmitNoticeProps || {},
@@ -62,7 +69,7 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
 				icon: action.icon,
 				pagenow: action.pageNow,
 				adminpage: action.adminPage,
-				onSubmitLabel: action.onSubmitLabel,
+				onSubmitLabel: getOnSubmitLabel( action ),
 				props: action.props,
 			};
 			return {
diff --git a/packages/js/customer-effort-score/src/store/test/actions.js b/packages/js/customer-effort-score/src/store/test/actions.js
new file mode 100644
index 00000000000..d4788c92320
--- /dev/null
+++ b/packages/js/customer-effort-score/src/store/test/actions.js
@@ -0,0 +1,102 @@
+/**
+ * Internal dependencies
+ */
+import TYPES from '../action-types';
+import { addCesSurvey, showCesModal } from '../actions';
+
+const survey = {
+	action: 'save_product',
+	title: 'How easy was it to save this product?',
+	description: 'Tell us about your experience.',
+	noticeLabel: 'Product saved',
+	firstQuestion: 'Saving this product was easy.',
+	secondQuestion: 'The save flow met my needs.',
+	icon: 'product',
+	pageNow: 'product',
+	adminPage: 'post-php',
+	props: { productType: 'simple' },
+};
+
+const actionCreators = [
+	{
+		name: 'addCesSurvey',
+		type: TYPES.ADD_CES_SURVEY,
+		create: ( labels ) => addCesSurvey( { ...survey, ...labels } ),
+		defaultLabel: undefined,
+	},
+	{
+		name: 'showCesModal',
+		type: TYPES.SHOW_CES_MODAL,
+		create: ( labels ) => showCesModal( { ...survey, ...labels } ),
+		defaultLabel: '',
+	},
+];
+
+const precedenceCases = [
+	{
+		caseName: 'prefers the canonical value',
+		labels: {
+			onSubmitLabel: 'Canonical label',
+			onsubmitLabel: 'Lower-camel label',
+			onsubmit_label: 'Snake-case label',
+		},
+		expected: 'Canonical label',
+	},
+	{
+		caseName: 'keeps an empty canonical value',
+		labels: {
+			onSubmitLabel: '',
+			onsubmitLabel: 'Lower-camel label',
+			onsubmit_label: 'Snake-case label',
+		},
+		expected: '',
+	},
+	{
+		caseName: 'keeps an empty lower-camel value',
+		labels: {
+			onSubmitLabel: null,
+			onsubmitLabel: '',
+			onsubmit_label: 'Snake-case label',
+		},
+		expected: '',
+	},
+	{
+		caseName: 'falls through nullish values',
+		labels: {
+			onSubmitLabel: null,
+			onsubmitLabel: null,
+			onsubmit_label: 'Snake-case label',
+		},
+		expected: 'Snake-case label',
+	},
+];
+
+describe.each( actionCreators )(
+	'$name',
+	( { create, defaultLabel, type } ) => {
+		it.each( [ 'onSubmitLabel', 'onsubmitLabel', 'onsubmit_label' ] )(
+			'normalizes the %s input field to onSubmitLabel',
+			( inputField ) => {
+				const action = create( { [ inputField ]: 'Share feedback' } );
+
+				expect( action ).toMatchObject( {
+					type,
+					onSubmitLabel: 'Share feedback',
+				} );
+				expect( action ).not.toHaveProperty( 'onsubmitLabel' );
+				expect( action ).not.toHaveProperty( 'onsubmit_label' );
+			}
+		);
+
+		it.each( precedenceCases )( '$caseName', ( { labels, expected } ) => {
+			expect( create( labels ).onSubmitLabel ).toBe( expected );
+		} );
+
+		it( 'uses the expected value when no label field is present', () => {
+			expect( create( {} ) ).toHaveProperty(
+				'onSubmitLabel',
+				defaultLabel
+			);
+		} );
+	}
+);
diff --git a/packages/js/customer-effort-score/src/store/test/reducer.js b/packages/js/customer-effort-score/src/store/test/reducer.js
new file mode 100644
index 00000000000..25c8f305043
--- /dev/null
+++ b/packages/js/customer-effort-score/src/store/test/reducer.js
@@ -0,0 +1,203 @@
+/**
+ * Internal dependencies
+ */
+import TYPES from '../action-types';
+import reducer from '../reducer';
+
+const surveyAction = {
+	action: 'save_product',
+	title: 'How easy was it to save this product?',
+	description: 'Tell us about your experience.',
+	noticeLabel: 'Product saved',
+	firstQuestion: 'Saving this product was easy.',
+	secondQuestion: 'The save flow met my needs.',
+	icon: 'product',
+	pageNow: 'product',
+	adminPage: 'post-php',
+	props: { productType: 'simple' },
+};
+
+const reducerCases = [
+	{
+		name: 'ADD_CES_SURVEY',
+		createAction: ( labels ) => ( {
+			type: TYPES.ADD_CES_SURVEY,
+			...surveyAction,
+			...labels,
+		} ),
+		getLabel: ( state ) => state.queue[ 0 ].onSubmitLabel,
+	},
+	{
+		name: 'SHOW_CES_MODAL',
+		createAction: ( labels ) => ( {
+			type: TYPES.SHOW_CES_MODAL,
+			surveyProps: surveyAction,
+			props: surveyAction.props,
+			onSubmitNoticeProps: { type: 'success' },
+			tracksProps: { source: 'product-editor' },
+			...labels,
+		} ),
+		getLabel: ( state ) => state.cesModalData.onSubmitLabel,
+	},
+];
+
+const precedenceCases = [
+	{
+		caseName: 'prefers the canonical action field',
+		labels: {
+			onSubmitLabel: 'Canonical label',
+			onsubmitLabel: 'Lower-camel label',
+			onsubmit_label: 'Snake-case label',
+		},
+		expected: 'Canonical label',
+	},
+	{
+		caseName: 'keeps an empty canonical action field',
+		labels: {
+			onSubmitLabel: '',
+			onsubmitLabel: 'Lower-camel label',
+			onsubmit_label: 'Snake-case label',
+		},
+		expected: '',
+	},
+	{
+		caseName: 'falls through a null canonical action field',
+		labels: {
+			onSubmitLabel: null,
+			onsubmitLabel: 'Lower-camel label',
+			onsubmit_label: 'Snake-case label',
+		},
+		expected: 'Lower-camel label',
+	},
+	{
+		caseName: 'keeps an empty lower-camel action field',
+		labels: {
+			onSubmitLabel: null,
+			onsubmitLabel: '',
+			onsubmit_label: 'Snake-case label',
+		},
+		expected: '',
+	},
+];
+
+describe( 'customer effort score reducer', () => {
+	describe.each( reducerCases )( '$name', ( { createAction, getLabel } ) => {
+		it.each( [ 'onSubmitLabel', 'onsubmitLabel', 'onsubmit_label' ] )(
+			'keeps a custom label from the %s action field',
+			( inputField ) => {
+				const state = reducer(
+					undefined,
+					createAction( { [ inputField ]: 'Share feedback' } )
+				);
+
+				expect( getLabel( state ) ).toBe( 'Share feedback' );
+			}
+		);
+
+		it.each( precedenceCases )( '$caseName', ( { labels, expected } ) => {
+			const state = reducer( undefined, createAction( labels ) );
+
+			expect( getLabel( state ) ).toBe( expected );
+		} );
+	} );
+
+	it( 'leaves the queue unchanged for a duplicate survey', () => {
+		const initialState = {
+			queue: [
+				{ action: 'save_product', onSubmitLabel: 'Save label' },
+				{
+					action: 'publish_product',
+					onSubmitLabel: 'Publish label',
+				},
+			],
+			cesModalData: undefined,
+			showCESModal: false,
+			showProductMVPFeedbackModal: false,
+		};
+
+		const state = reducer( initialState, {
+			type: TYPES.ADD_CES_SURVEY,
+			...surveyAction,
+			onSubmitLabel: 'Replacement label',
+		} );
+
+		expect( state ).toBe( initialState );
+	} );
+
+	it( 'normalizes persisted queue labels without changing legacy data or order', () => {
+		const existingItem = Object.freeze( {
+			id: 'existing',
+			action: 'existing_survey',
+			onSubmitLabel: 'Existing label',
+		} );
+		const queue = Object.freeze( [
+			Object.freeze( {
+				id: 'canonical',
+				action: 'canonical_survey',
+				onSubmitLabel: 'Canonical label',
+				onsubmitLabel: 'Lower-camel label',
+				onsubmit_label: 'Snake-case label',
+				unrelated: Object.freeze( { source: 'canonical' } ),
+			} ),
+			Object.freeze( {
+				id: 'lower-camel',
+				action: 'lower_camel_survey',
+				onsubmitLabel: 'Lower-camel label',
+				onsubmit_label: 'Snake-case label',
+			} ),
+			Object.freeze( {
+				id: 'snake-case',
+				action: 'snake_case_survey',
+				onsubmit_label: 'Snake-case label',
+			} ),
+			Object.freeze( {
+				id: 'no-label',
+				action: 'default_label_survey',
+			} ),
+		] );
+		const initialState = Object.freeze( {
+			queue: Object.freeze( [ existingItem ] ),
+			cesModalData: undefined,
+			showCESModal: false,
+			showProductMVPFeedbackModal: false,
+		} );
+		const action = Object.freeze( {
+			type: TYPES.SET_CES_SURVEY_QUEUE,
+			queue,
+		} );
+
+		const state = reducer( initialState, action );
+
+		expect( state.queue ).toEqual( [
+			existingItem,
+			{
+				...queue[ 0 ],
+				onSubmitLabel: 'Canonical label',
+			},
+			{
+				...queue[ 1 ],
+				onSubmitLabel: 'Lower-camel label',
+			},
+			{
+				...queue[ 2 ],
+				onSubmitLabel: 'Snake-case label',
+			},
+			{
+				...queue[ 3 ],
+				onSubmitLabel: undefined,
+			},
+		] );
+		expect( state.queue.map( ( item ) => item.id ) ).toEqual( [
+			'existing',
+			'canonical',
+			'lower-camel',
+			'snake-case',
+			'no-label',
+		] );
+		expect( state.queue[ 0 ] ).toBe( existingItem );
+		expect( state.queue[ 1 ] ).not.toBe( queue[ 0 ] );
+		expect( state.queue[ 2 ] ).not.toBe( queue[ 1 ] );
+		expect( state.queue[ 3 ] ).not.toBe( queue[ 2 ] );
+		expect( state.queue[ 4 ] ).not.toBe( queue[ 3 ] );
+	} );
+} );
diff --git a/plugins/woocommerce/changelog/fix-39052-normalize-ces-submit-label b/plugins/woocommerce/changelog/fix-39052-normalize-ces-submit-label
new file mode 100644
index 00000000000..8ecafd1fae0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-39052-normalize-ces-submit-label
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use the canonical customer effort score submit label property for inline survey dispatches.
diff --git a/plugins/woocommerce/src/Internal/Admin/CustomerEffortScoreTracks.php b/plugins/woocommerce/src/Internal/Admin/CustomerEffortScoreTracks.php
index 3593baf48ef..87b06737038 100644
--- a/plugins/woocommerce/src/Internal/Admin/CustomerEffortScoreTracks.php
+++ b/plugins/woocommerce/src/Internal/Admin/CustomerEffortScoreTracks.php
@@ -160,7 +160,7 @@ class CustomerEffortScoreTracks {
 						if ( $('.tags tbody > tr').length > initialCount ) {
 							// New tag detected.
 							clearInterval( interval );
-							wp.data.dispatch('wc/customer-effort-score').addCesSurvey({ action: '%s', title: '%s', firstQuestion: '%s', secondQuestion: '%s', onsubmitLabel: '%s' });
+							wp.data.dispatch('wc/customer-effort-score').addCesSurvey({ action: '%s', title: '%s', firstQuestion: '%s', secondQuestion: '%s', onSubmitLabel: '%s' });
 						} else {
 							// Form is no longer loading, most likely failed.
 							if ( $( '#addtag .submit .spinner.is-active' ).length < 1 ) {