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: '' } );
+	} );
+} );