Commit 3e9d29853b0 for woocommerce
commit 3e9d29853b0c0bc6bb86619dddb50146bbcda0f9
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Thu Jul 23 13:14:45 2026 +0100
Fix select value matching for extension-supplied option values (#66906)
* fix(settings): Canonicalize Settings UI schema option values
Core schemas built from legacy settings cast option values to string,
but native schema providers can supply any scalar. The client matches
options against the stored value with strict string comparison, so a
numeric option value never matches and select and radio fields render
blank despite a saved selection.
Canonicalize scalar option values and the selected values they match
to strings in resolve_schema(), at the source, so every schema
consumer sees one representation. A doing-it-wrong notice tells the
provider to supply strings; malformed entries pass through unchanged.
Refs #66558
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): Leave associative value arrays unchanged
canonicalize_scalar_list() ran every all-scalar array through
array_values(), so an associative value with a non-string member, like
array( 'tier' => 1 ), was flattened to a reindexed string list. That
dropped the keys and contradicted the contract that malformed entries
pass through for the provider to fix, while an all-string associative
array was already left untouched.
Guard with ArrayUtil::array_is_list() so only sequential lists are
canonicalized. Associative arrays now pass through unchanged and raise
no doing-it-wrong notice.
Refs #66558
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): Name both conversion causes in the notice
The doing-it-wrong notice always said the provider supplied non-string
option values, but it also fires when only the field-level selected
value was converted. A provider with correct string options and an int
value was pointed at the wrong array.
Broaden the message to option or field values so it covers both paths.
Refs #66558
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(settings): Cover float canonicalization
Int and bool casts were covered but float was not, and float is the
scalar where the PHP string cast has surprise potential. Lock in the
exact output for float option and field values.
Refs #66558
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Vlad Olaru <vlad.olaru@automattic.com>
diff --git a/plugins/woocommerce/changelog/fix-66558-settings-ui-option-values b/plugins/woocommerce/changelog/fix-66558-settings-ui-option-values
new file mode 100644
index 00000000000..a5a97c1f8d8
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66558-settings-ui-option-values
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Canonicalize non-string option values in Settings UI schemas so select and radio fields show the saved selection.
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
index 7e3bb1cff8a..8406b8d3f24 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
@@ -472,6 +472,7 @@ class SettingsUIRequestContext {
try {
$schema = $this->settings_ui_page->get_schema( $this->section );
+ $schema = SettingsUISchema::canonicalize_option_values( $schema );
$schema = $this->apply_section_navigation( $schema );
$schema = $this->apply_shell_header_visibility( $schema );
$this->schema = $this->ensure_drill_down_breadcrumbs( $schema );
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
index afbb6540aec..9676d9ddb65 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
@@ -7,6 +7,8 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Settings;
+use Automattic\WooCommerce\Internal\Utilities\ArrayUtil;
+
defined( 'ABSPATH' ) || exit;
/**
@@ -136,6 +138,121 @@ class SettingsUISchema {
);
}
+ /**
+ * Canonicalize option values supplied by native Settings UI schema providers.
+ *
+ * Schemas built from legacy settings always carry string option values, but
+ * native providers can supply any scalar. The client matches options against
+ * the stored value with strict string comparison, so scalar option values
+ * and the selected values they match are cast to strings here. Malformed
+ * entries remain unchanged for the provider to fix.
+ *
+ * @since 11.1.0
+ *
+ * @param array $schema Settings UI schema.
+ * @return array Schema with scalar option values canonicalized to strings.
+ */
+ public static function canonicalize_option_values( array $schema ): array {
+ if ( ! isset( $schema['groups'] ) || ! is_array( $schema['groups'] ) ) {
+ return $schema;
+ }
+
+ $converted_fields = array();
+
+ foreach ( $schema['groups'] as &$group ) {
+ if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
+ continue;
+ }
+
+ foreach ( $group['fields'] as &$field ) {
+ if (
+ ! is_array( $field ) ||
+ ! isset( $field['id'], $field['options'] ) ||
+ ! is_string( $field['id'] ) ||
+ ! is_array( $field['options'] )
+ ) {
+ continue;
+ }
+
+ $converted = false;
+
+ foreach ( $field['options'] as &$option ) {
+ if (
+ ! is_array( $option ) ||
+ ! array_key_exists( 'value', $option ) ||
+ is_string( $option['value'] ) ||
+ ! is_scalar( $option['value'] )
+ ) {
+ continue;
+ }
+
+ $option['value'] = (string) $option['value'];
+ $converted = true;
+ }
+ unset( $option );
+
+ if ( array_key_exists( 'value', $field ) ) {
+ if ( is_scalar( $field['value'] ) && ! is_string( $field['value'] ) ) {
+ $field['value'] = (string) $field['value'];
+ $converted = true;
+ } elseif ( is_array( $field['value'] ) ) {
+ $canonical_list = self::canonicalize_scalar_list( $field['value'] );
+ if ( null !== $canonical_list ) {
+ $field['value'] = $canonical_list;
+ $converted = true;
+ }
+ }
+ }
+
+ if ( $converted ) {
+ $converted_fields[] = $field['id'];
+ }
+ }
+ unset( $field );
+ }
+ unset( $group );
+
+ if ( ! empty( $converted_fields ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: comma-separated field ids. */
+ esc_html__( 'A Settings UI schema provider supplied non-string option or field values that WooCommerce converted for compatibility: %s. Update the provider to supply string values.', 'woocommerce' ),
+ esc_html( implode( ', ', array_unique( $converted_fields ) ) )
+ ),
+ '11.1.0'
+ );
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Canonicalize a list of scalar values to strings.
+ *
+ * @param array $values Candidate value list.
+ * @return array|null String list, or null when unchanged or not a scalar list.
+ */
+ private static function canonicalize_scalar_list( array $values ): ?array {
+ if ( ! ArrayUtil::array_is_list( $values ) ) {
+ return null;
+ }
+
+ $needs_conversion = false;
+
+ foreach ( $values as $item ) {
+ if ( ! is_scalar( $item ) ) {
+ return null;
+ }
+
+ if ( ! is_string( $item ) ) {
+ $needs_conversion = true;
+ }
+ }
+
+ return $needs_conversion ? array_map( 'strval', $values ) : null;
+ }
+
/**
* Transform a legacy field into the canonical schema.
*
diff --git a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
index 72f506bd620..d59a2a458a0 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Admin\Settings\SettingsSectionInterface;
use Automattic\WooCommerce\Admin\Settings\SettingsSectionRegistry;
use Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface;
use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUISchema;
use WC_Unit_Test_Case;
/**
@@ -159,6 +160,48 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
$this->assertArrayNotHasKey( 'registered_acme_payments_setting', $schema['groups'] );
}
+ /**
+ * @testdox Should canonicalize option values from a native Settings UI page provider.
+ */
+ public function test_canonicalizes_option_values_from_native_settings_ui_page(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_option_values' );
+
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register(
+ $this->get_registered_section_with_native_settings_ui_page(
+ null,
+ null,
+ null,
+ array(
+ array(
+ 'id' => 'acme_tier',
+ 'label' => 'Tier',
+ 'type' => 'select',
+ 'value' => 1,
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => 1,
+ ),
+ array(
+ 'label' => 'Two',
+ 'value' => 2,
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $context = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' );
+ $schema = $context->get_schema();
+
+ $this->assertFalse( $context->has_schema_failed() );
+ $field = $schema['groups']['native_group']['fields'][0];
+ $this->assertSame( '1', $field['value'], 'The selected value should follow its options to string form.' );
+ $this->assertSame( array( '1', '2' ), array_column( $field['options'], 'value' ), 'Scalar option values should canonicalize to strings.' );
+ }
+
/**
* @testdox Should invoke the native Settings UI page provider once per cached request context.
*/
@@ -582,10 +625,11 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
* @param callable|null $on_settings_ui_page_call Callback invoked every time the Settings UI page provider runs.
* @param array|null $shell Schema shell for the native page. Null uses the fixture default with custom section navigation.
* @param string|null $failure_stage Settings UI resolution stage that should fail.
+ * @param array|null $fields Schema fields for the native page. Null uses an empty field list.
* @return SettingsSectionInterface
*/
- private function get_registered_section_with_native_settings_ui_page( ?callable $on_settings_ui_page_call = null, ?array $shell = null, ?string $failure_stage = null ): SettingsSectionInterface {
- return new class( $on_settings_ui_page_call, $shell, $failure_stage ) extends SettingsSection {
+ private function get_registered_section_with_native_settings_ui_page( ?callable $on_settings_ui_page_call = null, ?array $shell = null, ?string $failure_stage = null, ?array $fields = null ): SettingsSectionInterface {
+ return new class( $on_settings_ui_page_call, $shell, $failure_stage, $fields ) extends SettingsSection {
/**
* Callback invoked every time the Settings UI page provider runs.
*
@@ -607,17 +651,26 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
*/
private ?string $failure_stage;
+ /**
+ * Schema fields for the native page, or null for an empty field list.
+ *
+ * @var array|null
+ */
+ private ?array $fields;
+
/**
* Constructor.
*
* @param callable|null $on_settings_ui_page_call Callback invoked every time the Settings UI page provider runs.
* @param array|null $shell Schema shell for the native page, or null for the fixture default.
* @param string|null $failure_stage Settings UI resolution stage that should fail.
+ * @param array|null $fields Schema fields for the native page, or null for an empty field list.
*/
- public function __construct( ?callable $on_settings_ui_page_call, ?array $shell, ?string $failure_stage ) {
+ public function __construct( ?callable $on_settings_ui_page_call, ?array $shell, ?string $failure_stage, ?array $fields ) {
$this->on_settings_ui_page_call = $on_settings_ui_page_call;
$this->shell = $shell;
$this->failure_stage = $failure_stage;
+ $this->fields = $fields;
}
/**
@@ -674,7 +727,7 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
( $this->on_settings_ui_page_call )();
}
- return new class( $this->shell, $this->failure_stage ) implements SettingsUIPageInterface {
+ return new class( $this->shell, $this->failure_stage, $this->fields ) implements SettingsUIPageInterface {
/**
* Schema shell, or null for the fixture default.
*
@@ -689,15 +742,24 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
*/
private ?string $failure_stage;
+ /**
+ * Schema fields, or null for an empty field list.
+ *
+ * @var array|null
+ */
+ private ?array $fields;
+
/**
* Constructor.
*
* @param array|null $shell Schema shell, or null for the fixture default.
* @param string|null $failure_stage Settings UI resolution stage that should fail.
+ * @param array|null $fields Schema fields, or null for an empty field list.
*/
- public function __construct( ?array $shell, ?string $failure_stage ) {
+ public function __construct( ?array $shell, ?string $failure_stage, ?array $fields ) {
$this->shell = $shell;
$this->failure_stage = $failure_stage;
+ $this->fields = $fields;
}
/**
@@ -739,7 +801,7 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
'native_group' => array(
'id' => 'native_group',
'title' => 'Native group',
- 'fields' => array(),
+ 'fields' => $this->fields ?? array(),
),
),
'save' => array(
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
index 0a61bd40551..61b58cc91b3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
@@ -396,4 +396,183 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
);
$this->assertSame( 'Option A', $field['options'][0]['label'] );
}
+
+ /**
+ * @testdox It canonicalizes scalar option values and the selected value to strings.
+ */
+ public function test_canonicalize_option_values_stringifies_scalar_option_values(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_option_values' );
+
+ $schema = SettingsUISchema::canonicalize_option_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_tier',
+ 'type' => 'select',
+ 'value' => 1,
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => 1,
+ ),
+ array(
+ 'label' => 'Enabled',
+ 'value' => true,
+ ),
+ ),
+ )
+ )
+ );
+
+ $field = $schema['groups']['main']['fields'][0];
+
+ $this->assertSame( '1', $field['value'] );
+ $this->assertSame( array( '1', '1' ), array_column( $field['options'], 'value' ), 'Boolean option values should use the PHP string cast, matching stored values.' );
+ }
+
+ /**
+ * @testdox It canonicalizes float option values with the PHP string cast.
+ */
+ public function test_canonicalize_option_values_stringifies_float_values(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_option_values' );
+
+ $schema = SettingsUISchema::canonicalize_option_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_rate',
+ 'type' => 'select',
+ 'value' => 1.5,
+ 'options' => array(
+ array(
+ 'label' => 'Half',
+ 'value' => 0.5,
+ ),
+ array(
+ 'label' => 'One and a half',
+ 'value' => 1.5,
+ ),
+ ),
+ )
+ )
+ );
+
+ $field = $schema['groups']['main']['fields'][0];
+
+ $this->assertSame( '1.5', $field['value'] );
+ $this->assertSame( array( '0.5', '1.5' ), array_column( $field['options'], 'value' ), 'Float option values should use the PHP string cast, matching stored values.' );
+ }
+
+ /**
+ * @testdox It canonicalizes scalar members of a multiselect value list.
+ */
+ public function test_canonicalize_option_values_stringifies_value_lists(): void {
+ $this->setExpectedIncorrectUsage( SettingsUISchema::class . '::canonicalize_option_values' );
+
+ $schema = SettingsUISchema::canonicalize_option_values(
+ $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_tiers',
+ 'type' => 'array',
+ 'value' => array( 1, '2' ),
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => 1,
+ ),
+ array(
+ 'label' => 'Two',
+ 'value' => 2,
+ ),
+ ),
+ )
+ )
+ );
+
+ $field = $schema['groups']['main']['fields'][0];
+
+ $this->assertSame( array( '1', '2' ), $field['value'] );
+ $this->assertSame( array( '1', '2' ), array_column( $field['options'], 'value' ) );
+ }
+
+ /**
+ * @testdox It leaves schemas with string option values untouched.
+ */
+ public function test_canonicalize_option_values_leaves_canonical_schemas_untouched(): void {
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_tier',
+ 'type' => 'select',
+ 'value' => '1',
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => '1',
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( $schema, SettingsUISchema::canonicalize_option_values( $schema ), 'Canonical schemas should pass through unchanged without a doing-it-wrong notice.' );
+ }
+
+ /**
+ * @testdox It leaves malformed option entries and values unchanged.
+ */
+ public function test_canonicalize_option_values_leaves_malformed_entries_unchanged(): void {
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_tier',
+ 'type' => 'select',
+ 'value' => new \stdClass(),
+ 'options' => array(
+ array(
+ 'label' => 'Nested',
+ 'value' => array( 'not-scalar' ),
+ ),
+ 'not-an-array',
+ ),
+ )
+ );
+
+ $this->assertEquals( $schema, SettingsUISchema::canonicalize_option_values( $schema ), 'Malformed entries should pass through for the provider to fix.' );
+ }
+
+ /**
+ * @testdox It leaves associative value arrays unchanged.
+ */
+ public function test_canonicalize_option_values_leaves_associative_values_unchanged(): void {
+ $schema = $this->get_native_schema_with_field(
+ array(
+ 'id' => 'acme_tiers',
+ 'type' => 'array',
+ 'value' => array( 'tier' => 1 ),
+ 'options' => array(
+ array(
+ 'label' => 'One',
+ 'value' => '1',
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( $schema, SettingsUISchema::canonicalize_option_values( $schema ), 'Associative value arrays should pass through unreindexed for the provider to fix.' );
+ }
+
+ /**
+ * Build a minimal native schema with one field.
+ *
+ * @param array $field Field definition.
+ * @return array
+ */
+ private function get_native_schema_with_field( array $field ): array {
+ return array(
+ 'id' => 'acme',
+ 'title' => 'Acme',
+ 'groups' => array(
+ 'main' => array(
+ 'id' => 'main',
+ 'fields' => array( $field ),
+ ),
+ ),
+ );
+ }
}