Commit c2fb9cf502e for woocommerce
commit c2fb9cf502e7179e9630d5e9435dbbc07c898044
Author: Raluca Stan <ralucastn@gmail.com>
Date: Thu Sep 10 16:50:57 2026 +0200
Show unavailable saved country as a disabled option in checkout (#68056)
* Show unavailable saved country as disabled option in checkout
* Add changelog entry for checkout country selector fix
* Apply suggestion from @ralucaStan
* Append unavailable country last and add single-option selection test
* Guard the countries setting before reading the unavailable country name
* Guard the countries setting read in the blocks settings constants
diff --git a/plugins/woocommerce/changelog/fix-58084-checkout-unavailable-country-select b/plugins/woocommerce/changelog/fix-58084-checkout-unavailable-country-select
new file mode 100644
index 00000000000..3ce95ab1539
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-58084-checkout-unavailable-country-select
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show a returning customer's saved country as a disabled option in the checkout country dropdown when the store no longer sells or ships to that country
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/country-input/country-input.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/country-input/country-input.tsx
index 9371e1fe884..0946d5cc082 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/country-input/country-input.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/country-input/country-input.tsx
@@ -3,6 +3,8 @@
*/
import { useMemo } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
+import { getSettingWithCoercion } from '@woocommerce/settings';
+import { isObject, isString, objectHasProp } from '@woocommerce/types';
import clsx from 'clsx';
/**
@@ -24,13 +26,39 @@ export const CountryInput = ( {
required = false,
}: CountryInputWithCountriesProps ): JSX.Element => {
const options = useMemo< SelectOption[] >( () => {
- return Object.entries( countries ).map(
+ const countryOptions: SelectOption[] = Object.entries( countries ).map(
( [ countryCode, countryName ] ) => ( {
value: countryCode,
label: decodeEntities( countryName ),
} )
);
- }, [ countries ] );
+ // Keep an unavailable saved country in the list as a disabled option.
+ // With no matching option the select drifts off the stored value, and
+ // re-picking the displayed country fires no change event, so the error
+ // could never be cleared.
+ const selectedCountry = typeof value === 'string' ? value : '';
+ if (
+ selectedCountry &&
+ ! objectHasProp( countries, selectedCountry )
+ ) {
+ const allCountries = getSettingWithCoercion(
+ 'countries',
+ {},
+ isObject
+ );
+ const countryName = allCountries[ selectedCountry ];
+ countryOptions.push( {
+ value: selectedCountry,
+ label: decodeEntities(
+ isString( countryName ) && countryName
+ ? countryName
+ : selectedCountry
+ ),
+ disabled: true,
+ } );
+ }
+ return countryOptions;
+ }, [ countries, value ] );
return (
<Select
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/country-input/test/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/country-input/test/index.tsx
new file mode 100644
index 00000000000..a9ceffdf3b5
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/country-input/test/index.tsx
@@ -0,0 +1,155 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from '@wordpress/element';
+import { allSettings } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import CountryInput from '../country-input';
+
+const allowedCountries = {
+ AT: 'Austria',
+ US: 'United States (US)',
+} as const;
+
+const defaultProps = {
+ id: 'shipping-country',
+ label: 'Country/Region',
+ countries: allowedCountries,
+ onChange: jest.fn(),
+};
+
+describe( 'CountryInput', () => {
+ beforeEach( () => {
+ allSettings.countries = {
+ ...allowedCountries,
+ GB: 'United Kingdom (UK)',
+ };
+ } );
+
+ afterEach( () => {
+ allSettings.countries = [];
+ jest.clearAllMocks();
+ } );
+
+ it( 'renders the allowed countries as options', () => {
+ render( <CountryInput { ...defaultProps } value="US" /> );
+
+ expect(
+ screen.getByRole( 'option', { name: 'United States (US)' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'option', { name: 'Austria' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows the selected country as a disabled option when it is not in the allowed list', () => {
+ render( <CountryInput { ...defaultProps } value="GB" /> );
+
+ const unavailableOption = screen.getByRole( 'option', {
+ name: 'United Kingdom (UK)',
+ } ) as HTMLOptionElement;
+
+ expect( unavailableOption ).toBeInTheDocument();
+ expect( unavailableOption.disabled ).toBe( true );
+ expect( unavailableOption.selected ).toBe( true );
+ expect( screen.getByLabelText( 'Country/Region' ) ).toHaveValue( 'GB' );
+ } );
+
+ it( 'does not add an extra option when the selected country is allowed', () => {
+ render( <CountryInput { ...defaultProps } value="US" /> );
+
+ // Placeholder + the two allowed countries only.
+ expect( screen.getAllByRole( 'option' ) ).toHaveLength( 3 );
+ } );
+
+ it( 'appends the unavailable country after the allowed countries', () => {
+ render( <CountryInput { ...defaultProps } value="GB" /> );
+
+ const optionLabels = screen
+ .getAllByRole( 'option' )
+ .map( ( option ) => option.textContent );
+
+ expect( optionLabels ).toEqual( [
+ 'Select a country/region',
+ 'Austria',
+ 'United States (US)',
+ 'United Kingdom (UK)',
+ ] );
+ } );
+
+ it( 'falls back to the country code when the full country list has no name for it', () => {
+ render( <CountryInput { ...defaultProps } value="XX" /> );
+
+ expect(
+ screen.getByRole( 'option', { name: 'XX' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'falls back to the country code when the countries setting is not an object', () => {
+ // A plugin can replace the setting through the shared settings filter.
+ allSettings.countries = null as unknown as typeof allSettings.countries;
+
+ render( <CountryInput { ...defaultProps } value="GB" /> );
+
+ const unavailableOption = screen.getByRole( 'option', {
+ name: 'GB',
+ } ) as HTMLOptionElement;
+
+ expect( unavailableOption ).toBeInTheDocument();
+ expect( unavailableOption.disabled ).toBe( true );
+ expect( screen.getByLabelText( 'Country/Region' ) ).toHaveValue( 'GB' );
+ } );
+
+ it( 'removes the unavailable option once an allowed country is selected', () => {
+ const { rerender } = render(
+ <CountryInput { ...defaultProps } value="GB" />
+ );
+
+ expect(
+ screen.getByRole( 'option', { name: 'United Kingdom (UK)' } )
+ ).toBeInTheDocument();
+
+ rerender( <CountryInput { ...defaultProps } value="US" /> );
+
+ expect(
+ screen.queryByRole( 'option', { name: 'United Kingdom (UK)' } )
+ ).not.toBeInTheDocument();
+ expect( screen.getByLabelText( 'Country/Region' ) ).toHaveValue( 'US' );
+ } );
+
+ it( 'lets the user pick the only allowed country when the saved one is unavailable', async () => {
+ const onChange = jest.fn();
+ const ControlledCountryInput = () => {
+ const [ country, setCountry ] = useState( 'GB' );
+ return (
+ <CountryInput
+ { ...defaultProps }
+ countries={ { US: 'United States (US)' } }
+ value={ country }
+ onChange={ ( newCountry: string ) => {
+ onChange( newCountry );
+ setCountry( newCountry );
+ } }
+ />
+ );
+ };
+ render( <ControlledCountryInput /> );
+
+ const select = screen.getByLabelText( 'Country/Region' );
+ expect( select ).toHaveValue( 'GB' );
+
+ await userEvent.selectOptions( select, 'US' );
+
+ expect( onChange ).toHaveBeenCalledTimes( 1 );
+ expect( onChange ).toHaveBeenCalledWith( 'US' );
+ expect( select ).toHaveValue( 'US' );
+ expect(
+ screen.queryByRole( 'option', { name: 'United Kingdom (UK)' } )
+ ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/settings/blocks/constants.ts b/plugins/woocommerce/client/blocks/assets/js/settings/blocks/constants.ts
index 5f602d0ed12..515effcbab7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/settings/blocks/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/settings/blocks/constants.ts
@@ -1,8 +1,12 @@
/**
* External dependencies
*/
-import { getSetting, STORE_PAGES } from '@woocommerce/settings';
-import { CountryData } from '@woocommerce/types';
+import {
+ getSetting,
+ getSettingWithCoercion,
+ STORE_PAGES,
+} from '@woocommerce/settings';
+import { CountryData, isObject, isString } from '@woocommerce/types';
import type {
OrderForm,
AddressForm,
@@ -68,7 +72,7 @@ type FieldsLocations = {
};
// Contains country names.
-const countries = getSetting< Record< string, string > >( 'countries', {} );
+const countries = getSettingWithCoercion( 'countries', {}, isObject );
// Contains country settings.
const countryData = getSetting< Record< string, CountryData > >(
@@ -82,7 +86,8 @@ export const ALLOWED_COUNTRIES = Object.fromEntries(
return countryData[ countryCode ].allowBilling === true;
} )
.map( ( countryCode ) => {
- return [ countryCode, countries[ countryCode ] || '' ];
+ const countryName = countries[ countryCode ];
+ return [ countryCode, isString( countryName ) ? countryName : '' ];
} )
);
@@ -92,7 +97,8 @@ export const SHIPPING_COUNTRIES = Object.fromEntries(
return countryData[ countryCode ].allowShipping === true;
} )
.map( ( countryCode ) => {
- return [ countryCode, countries[ countryCode ] || '' ];
+ const countryName = countries[ countryCode ];
+ return [ countryCode, isString( countryName ) ? countryName : '' ];
} )
);
diff --git a/plugins/woocommerce/client/blocks/assets/js/settings/blocks/test/constants.ts b/plugins/woocommerce/client/blocks/assets/js/settings/blocks/test/constants.ts
new file mode 100644
index 00000000000..a566e05cd42
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/settings/blocks/test/constants.ts
@@ -0,0 +1,74 @@
+/**
+ * External dependencies
+ */
+import type { CountryData } from '@woocommerce/types';
+
+type Constants = typeof import('../constants');
+
+const countryData: Record< string, CountryData > = {
+ US: {
+ allowBilling: true,
+ allowShipping: true,
+ states: {},
+ locale: {} as CountryData[ 'locale' ],
+ },
+ GB: {
+ allowBilling: false,
+ allowShipping: false,
+ states: {},
+ locale: {} as CountryData[ 'locale' ],
+ },
+};
+
+// The country maps are computed when the module loads, so every case needs a
+// fresh module registry seeded with its own `countries` setting.
+const loadConstants = ( countries: unknown ): Constants => {
+ window.wcSettings = { ...window.wcSettings, countries, countryData };
+ let constants: Constants | null = null;
+ jest.isolateModules( () => {
+ constants = require( '../constants' );
+ } );
+ if ( ! constants ) {
+ throw new Error( 'Constants module did not load.' );
+ }
+ return constants;
+};
+
+describe( 'country constants', () => {
+ const originalSettings = window.wcSettings;
+
+ afterEach( () => {
+ window.wcSettings = originalSettings;
+ } );
+
+ it( 'maps allowed countries to their names', () => {
+ const { ALLOWED_COUNTRIES, SHIPPING_COUNTRIES } = loadConstants( {
+ US: 'United States (US)',
+ GB: 'United Kingdom (UK)',
+ } );
+
+ expect( ALLOWED_COUNTRIES ).toEqual( { US: 'United States (US)' } );
+ expect( SHIPPING_COUNTRIES ).toEqual( { US: 'United States (US)' } );
+ } );
+
+ it.each( [
+ [ 'null', null ],
+ [ 'an array', [] ],
+ [ 'a string', 'broken' ],
+ ] )(
+ 'does not throw when the countries setting is %s',
+ ( _label, countries ) => {
+ const { ALLOWED_COUNTRIES, SHIPPING_COUNTRIES } =
+ loadConstants( countries );
+
+ expect( ALLOWED_COUNTRIES ).toEqual( { US: '' } );
+ expect( SHIPPING_COUNTRIES ).toEqual( { US: '' } );
+ }
+ );
+
+ it( 'falls back to an empty name when the entry is not a string', () => {
+ const { ALLOWED_COUNTRIES } = loadConstants( { US: 42 } );
+
+ expect( ALLOWED_COUNTRIES ).toEqual( { US: '' } );
+ } );
+} );