Commit ceb46de3fea for woocommerce

commit ceb46de3fea3f329fc8e30611e8b11fb05896492
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Fri May 29 23:03:21 2026 +0800

    Fix Customers analytics screen crashing on certain country values (#64633)

    fix: customers report cell renderer crashes on some country values

    The customers analytics screen could fail to render with React error #31
    when a customer record's country field happened to coerce into a value
    that hits an Array index or prototype name (e.g. "0", "find"). The cell
    renderer was treating the countries data store array as a string-keyed
    map, so countries["0"] returned the first country object instead of
    undefined, which React then refused to render as a child of the cell's
    screen-reader span.

    Replace the array-as-map lookup with Array#find by code, and decode
    HTML entities on the resolved name (the legacy wcSettings.countries map
    was already decoded; the REST data/countries response is not).

    Add a Jest test covering the regression and the entity-decoding contract.

    Fixes #64555

diff --git a/plugins/woocommerce/changelog/64555-fix-customers-report-country-render b/plugins/woocommerce/changelog/64555-fix-customers-report-country-render
new file mode 100644
index 00000000000..2ef65fb5656
--- /dev/null
+++ b/plugins/woocommerce/changelog/64555-fix-customers-report-country-render
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Customers analytics screen failing to render when a customer record contains a country value that collides with an Array index or property name.
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
index d3f77850281..14647530251 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
@@ -5,6 +5,7 @@ import { __, _n } from '@wordpress/i18n';
 import { Fragment, useContext } from '@wordpress/element';
 import { useSelect } from '@wordpress/data';
 import { Tooltip } from '@wordpress/components';
+import { decodeEntities } from '@wordpress/html-entities';
 import { Date, Link, Pill } from '@woocommerce/components';
 import { formatValue } from '@woocommerce/number';
 import { getAdminLink } from '@woocommerce/settings';
@@ -108,9 +109,8 @@ function CustomersReportTable( {
 	};

 	const getCountryName = ( code ) => {
-		return typeof countries[ code ] !== 'undefined'
-			? countries[ code ]
-			: null;
+		const country = countries.find( ( c ) => c.code === code );
+		return country ? decodeEntities( country.name ) : null;
 	};

 	const getRowsContent = ( customers ) => {
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
new file mode 100644
index 00000000000..defa68379e8
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
@@ -0,0 +1,160 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import CustomersReportTable from '../table';
+
+const captured = { getRowsContent: null };
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	useSelect: jest.fn(),
+} ) );
+
+const mockCountriesStore = ( countries ) => {
+	useSelect.mockReturnValue( {
+		countries,
+		loadingCountries: false,
+	} );
+};
+
+jest.mock( '@woocommerce/data', () => ( {
+	...jest.requireActual( '@woocommerce/data' ),
+	COUNTRIES_STORE_NAME: 'wc/admin/countries',
+} ) );
+
+jest.mock( '@woocommerce/currency', () => {
+	const React = require( 'react' );
+	const config = {
+		formatAmount: ( v ) => String( v ),
+		formatDecimal: ( v ) => v,
+		getCurrencyConfig: () => ( {} ),
+	};
+	return {
+		CurrencyContext: React.createContext( config ),
+		CurrencyFactory: () => config,
+	};
+} );
+
+jest.mock( '~/utils/admin-settings', () => ( {
+	getAdminSetting: ( _key, fallback ) => fallback,
+} ) );
+
+jest.mock( '../../../components/report-table', () => ( {
+	__esModule: true,
+	default: ( props ) => {
+		captured.getRowsContent = props.getRowsContent;
+		return null;
+	},
+} ) );
+
+const baseCustomer = {
+	id: 1,
+	name: 'Alice',
+	username: 'alice',
+	email: 'alice@example.com',
+	user_id: null,
+	date_last_active: null,
+	date_registered: null,
+	orders_count: 0,
+	total_spend: 0,
+	avg_order_value: 0,
+	postcode: '',
+	city: '',
+	state: '',
+	country: '',
+};
+
+// Country cell is the 9th column (0-indexed: 8) per getHeadersContent in table.js.
+const COUNTRY_COL = 8;
+
+function getCountryCell( customer ) {
+	captured.getRowsContent = null;
+	render( <CustomersReportTable query={ {} } /> );
+	const rows = captured.getRowsContent( [ customer ] );
+	return rows[ 0 ][ COUNTRY_COL ];
+}
+
+function renderCellDisplay( display ) {
+	return render(
+		<table>
+			<tbody>
+				<tr>
+					<td>{ display }</td>
+				</tr>
+			</tbody>
+		</table>
+	);
+}
+
+describe( 'CustomersReportTable country cell', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'renders the decoded country name for a known country code', () => {
+		mockCountriesStore( [
+			{ code: 'FR', name: 'France', states: [] },
+			{ code: 'IT', name: 'Italy', states: [] },
+		] );
+		const cell = getCountryCell( { ...baseCustomer, country: 'FR' } );
+
+		expect( cell.value ).toBe( 'FR' );
+
+		const { getByText, getAllByText } = renderCellDisplay( cell.display );
+		// The aria-hidden span shows the ISO code.
+		expect( getByText( 'FR' ) ).toBeInTheDocument();
+		// The screen-reader span shows the human-readable name.
+		expect( getAllByText( 'France' ).length ).toBeGreaterThan( 0 );
+	} );
+
+	it( 'decodes HTML entities in country names', () => {
+		mockCountriesStore( [
+			{ code: 'CI', name: 'C&ocirc;te d&#039;Ivoire', states: [] },
+		] );
+		const cell = getCountryCell( { ...baseCustomer, country: 'CI' } );
+
+		const { getAllByText } = renderCellDisplay( cell.display );
+		expect( getAllByText( "Côte d'Ivoire" ).length ).toBeGreaterThan( 0 );
+	} );
+
+	it( 'renders without crashing when the country code is unknown', () => {
+		mockCountriesStore( [ { code: 'FR', name: 'France', states: [] } ] );
+		const cell = getCountryCell( { ...baseCustomer, country: 'XX' } );
+
+		expect( () => renderCellDisplay( cell.display ) ).not.toThrow();
+	} );
+
+	// Regression for woocommerce/woocommerce#64555. Before the fix, getCountryName
+	// did `countries[ code ]`, which on an Array treats the key as an index.
+	// A customer record with country = "0" therefore resolved to the first
+	// country object, which React then refused to render as a child.
+	it( 'does not return a country object when the country code coerces to an array index (#64555)', () => {
+		mockCountriesStore( [
+			{
+				code: 'FR',
+				name: 'France',
+				states: [],
+				_links: { self: [ { href: '' } ] },
+			},
+		] );
+		const cell = getCountryCell( { ...baseCustomer, country: '0' } );
+
+		expect( () => renderCellDisplay( cell.display ) ).not.toThrow();
+	} );
+
+	it( 'does not return an Array prototype value when the country code is a method name (#64555)', () => {
+		mockCountriesStore( [ { code: 'FR', name: 'France', states: [] } ] );
+		const cell = getCountryCell( {
+			...baseCustomer,
+			country: 'find',
+		} );
+
+		expect( () => renderCellDisplay( cell.display ) ).not.toThrow();
+	} );
+} );