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ôte d'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();
+ } );
+} );