Commit cddfe06582c for woocommerce
commit cddfe06582c538ac1520de586dc9324c95ec6d7b
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Fri Sep 11 18:11:39 2026 +0100
Simplify Settings UI updates and preserve extension registrations (#68408)
* refactor: remove experimental settings save and navigation APIs
* fix: preserve unrelated settings extension registrations
* fix: retain explanations for disabled settings fields
* perf: reuse static DataForm group configuration
* chore: add settings cleanup changelogs
* fix: preserve settings save and navigation extension APIs
* chore: align settings cleanup changelogs with revised scope
* refactor: share settings navigation rendering and link inspection
* refactor: store settings groups with their form configuration
* chore: add settings navigation refactor changelog
* fix(settings-ui): prune superseded registration entries
* fix(settings-ui): preserve downloads while settings are dirty
* docs(changelog): note Settings UI download fix
---------
Co-authored-by: Ahmed <ahmed.el.azzabi@automattic.com>
diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index 3382c7f0c68..40bbbfccf13 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -204,6 +204,12 @@ For legacy country and page selectors, the adapter creates the same option list
The default save adapter is `form_post`, which serializes hidden inputs so `WC_Admin_Settings::save_fields()` continues to save the submitted values.
+## Extension registration and saving
+
+Repeated extension registrations replace only matching entries. Registering a replacement control preserves unrelated controls, visibility predicates, save handlers, and regions in the same scope.
+
+Use `form_post` for the existing PHP settings save flow. Integrations with separate persistence requirements can continue using the experimental `custom` save adapter and registered save handlers. The settings page owns the Save button, busy and error states, dirty-state reset, and navigation protection; the handler supplies the persistence operation. Both paths use DataForm for field rendering and editing.
+
## Custom component migration
If a field needs a custom React UI, declare a component name in the PHP field schema:
diff --git a/packages/js/settings-ui/changelog/fix-preserve-download-links b/packages/js/settings-ui/changelog/fix-preserve-download-links
new file mode 100644
index 00000000000..c3550832fda
--- /dev/null
+++ b/packages/js/settings-ui/changelog/fix-preserve-download-links
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve download links when the Settings UI has unsaved changes.
diff --git a/packages/js/settings-ui/changelog/refactor-bounded-settings-ui-cleanup b/packages/js/settings-ui/changelog/refactor-bounded-settings-ui-cleanup
new file mode 100644
index 00000000000..26268ba9e07
--- /dev/null
+++ b/packages/js/settings-ui/changelog/refactor-bounded-settings-ui-cleanup
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve unrelated settings extension registrations and disabled-field explanations, and simplify DataForm updates.
diff --git a/packages/js/settings-ui/changelog/refactor-settings-navigation-internals b/packages/js/settings-ui/changelog/refactor-settings-navigation-internals
new file mode 100644
index 00000000000..b1b14b29edb
--- /dev/null
+++ b/packages/js/settings-ui/changelog/refactor-settings-navigation-internals
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Share settings navigation rendering and link inspection, and simplify storage of prebuilt DataForm group configuration.
diff --git a/packages/js/settings-ui/src/dataform-adapter.tsx b/packages/js/settings-ui/src/dataform-adapter.tsx
index 06d691550b4..bb55c8ad1ac 100644
--- a/packages/js/settings-ui/src/dataform-adapter.tsx
+++ b/packages/js/settings-ui/src/dataform-adapter.tsx
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+import { createElement } from '@wordpress/element';
import type {
Field,
FieldTypeName,
@@ -273,16 +274,31 @@ export const buildDataFormField = (
options.context
);
+ const disabled = isFieldDisabled( settingsField );
+ const help = createSettingsHelpElement( settingsField.description );
+ const disabledTooltip =
+ settingsField.customAttributes?.[ 'disabled-tooltip' ];
+ const description =
+ disabled && typeof disabledTooltip === 'string' && disabledTooltip
+ ? createElement(
+ 'span',
+ null,
+ help,
+ help ? ' ' : null,
+ disabledTooltip
+ )
+ : help;
+
const field: Field< SettingsValues > = {
id: settingsField.id,
label: settingsField.label,
- description: createSettingsHelpElement( settingsField.description ),
+ description,
placeholder: settingsField.placeholder,
type: descriptor?.type,
elements: settingsField.options,
isValid: buildValidationRules( settingsField, descriptor ),
isVisible: createIsVisible( settingsField, options ),
- isDisabled: isFieldDisabled( settingsField ),
+ isDisabled: disabled,
};
if ( registeredComponent ) {
@@ -342,8 +358,11 @@ const buildGroupFormField = ( group: SettingsUIGroup ): FormField => ( {
export const createDataFormAdapter = (
options: DataFormAdapterOptions
): DataFormAdapter => {
- const groups = Object.values( options.schema.groups );
- const fields = groups.flatMap( ( group ) =>
+ const groups = Object.values( options.schema.groups ).map( ( group ) => ( {
+ group,
+ form: buildGroupFormField( group ),
+ } ) );
+ const fields = groups.flatMap( ( { group } ) =>
group.fields.map( ( field ) => buildDataFormField( field, options ) )
);
const fieldsById = new Map(
@@ -382,8 +401,8 @@ export const createDataFormAdapter = (
const getForm = ( values: SettingsValues ): Form => ( {
fields: groups
- .filter( ( group ) => isGroupVisible( group, values ) )
- .map( buildGroupFormField ),
+ .filter( ( { group } ) => isGroupVisible( group, values ) )
+ .map( ( { form } ) => form ),
} );
return { fields, getForm };
diff --git a/packages/js/settings-ui/src/registry.ts b/packages/js/settings-ui/src/registry.ts
index 47353a06f48..0f4f75a6534 100644
--- a/packages/js/settings-ui/src/registry.ts
+++ b/packages/js/settings-ui/src/registry.ts
@@ -24,8 +24,6 @@ const registrationMapKeys = [
'regions',
] as const;
-type RegistrationMapKey = ( typeof registrationMapKeys )[ number ];
-
const isPlainRecord = ( value: unknown ): value is Record< string, unknown > =>
typeof value === 'object' && value !== null && ! Array.isArray( value );
@@ -100,44 +98,48 @@ const getScopeKey = ( scope: SettingsExtensionRegistration[ 'scope' ] ) =>
hasSectionScope( scope ) ? scope.section || 'default' : '*'
}`;
-const hasDuplicateScopeAndKeys = (
- registration: SettingsExtensionRegistration,
- key: RegistrationMapKey
-) => {
- const entries = registration[ key ];
- if ( ! entries ) {
- return;
- }
-
- const incomingKeys = Object.keys( entries );
- if ( incomingKeys.length === 0 ) {
- return;
- }
-
- const scopeKey = getScopeKey( registration.scope );
- for ( const existing of registrations ) {
- if ( getScopeKey( existing.scope ) !== scopeKey ) {
+const pruneReplacedEntries = ( incoming: SettingsExtensionRegistration ) => {
+ let hasDuplicateKeys = false;
+ for ( let i = registrations.length - 1; i >= 0; i-- ) {
+ let existing = registrations[ i ];
+ if (
+ existing.scope.page !== incoming.scope.page ||
+ existing.scope.section !== incoming.scope.section
+ ) {
continue;
}
- const existingEntries = existing[ key ];
- if ( ! existingEntries ) {
- continue;
+ for ( const key of registrationMapKeys ) {
+ const entries = existing[ key ];
+ const replacements = incoming[ key ];
+ if ( ! entries || ! replacements ) {
+ continue;
+ }
+
+ const remaining = Object.entries( entries ).filter( ( [ id ] ) => {
+ const replaced =
+ Object.prototype.hasOwnProperty.call( replacements, id ) &&
+ typeof replacements[ id ] !== 'undefined';
+ hasDuplicateKeys ||= replaced;
+ return ! replaced;
+ } );
+ existing = {
+ ...existing,
+ [ key ]: Object.fromEntries( remaining ),
+ };
}
if (
- incomingKeys.some( ( entryKey ) =>
- Object.prototype.hasOwnProperty.call(
- existingEntries,
- entryKey
- )
+ registrationMapKeys.some(
+ ( key ) => Object.keys( existing[ key ] ?? {} ).length > 0
)
) {
- return true;
+ registrations[ i ] = existing;
+ } else {
+ registrations.splice( i, 1 );
}
}
-
- return false;
+ return hasDuplicateKeys;
};
export const registerSettingsExtension = (
@@ -150,25 +152,15 @@ export const registerSettingsExtension = (
return;
}
- const hasDuplicateKeys = registrationMapKeys.some( ( key ) =>
- hasDuplicateScopeAndKeys( registration, key )
- );
+ const hasDuplicateKeys = pruneReplacedEntries( registration );
if ( hasDuplicateKeys ) {
warn(
`Registration already exists for scope "${ getScopeKey(
registration.scope
- ) }". Replacing the existing registration.`,
+ ) }". Replacing the conflicting entries.`,
{ registration }
);
- for ( let i = registrations.length - 1; i >= 0; i-- ) {
- if (
- getScopeKey( registrations[ i ].scope ) ===
- getScopeKey( registration.scope )
- ) {
- registrations.splice( i, 1 );
- }
- }
}
registrations.push( registration );
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index e89af3296b8..22ed00a02f5 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -26,6 +26,7 @@ import { resolveRegionComponent, resolveSaveHandler } from './registry';
import type {
SettingsUIField,
SettingsUIShellBadgeIntent,
+ SettingsUIShellNavigationItem,
SettingsUISaveStrategy,
SettingsUISchema,
SettingsFieldContext,
@@ -109,7 +110,7 @@ const setFormPostRedirectInput = ( form: HTMLFormElement, href: string ) => {
redirectInput.value = href;
};
-const shouldPromptForNavigation = ( event: MouseEvent ) => {
+const getNavigationHref = ( event: MouseEvent ) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
@@ -118,38 +119,31 @@ const shouldPromptForNavigation = ( event: MouseEvent ) => {
event.shiftKey ||
event.altKey
) {
- return false;
+ return undefined;
}
const target = event.target;
if ( ! ( target instanceof Element ) ) {
- return false;
+ return undefined;
}
const link = target.closest( 'a[href]' );
if ( ! ( link instanceof HTMLAnchorElement ) ) {
- return false;
- }
-
- if ( link.target && link.target !== '_self' ) {
- return false;
+ return undefined;
}
- return Boolean( link.href ) && link.href !== window.location.href;
-};
-
-const getNavigationHref = ( event: MouseEvent ) => {
- const target = event.target;
-
- if ( ! ( target instanceof Element ) ) {
+ if (
+ link.hasAttribute( 'download' ) ||
+ ( link.target && link.target.toLowerCase() !== '_self' )
+ ) {
return undefined;
}
- const link = target.closest( 'a[href]' );
-
- return link instanceof HTMLAnchorElement ? link.href : undefined;
+ return link.href && link.href !== window.location.href
+ ? link.href
+ : undefined;
};
const UnsavedChangesModal = ( {
@@ -278,6 +272,32 @@ export class SettingsUIErrorBoundary extends Component<
}
}
+const SettingsNavigation = ( {
+ items,
+ label,
+}: {
+ items?: SettingsUIShellNavigationItem[];
+ label: string;
+} ) =>
+ items?.length ? (
+ <nav className="wc-settings-ui-shell__tabs" aria-label={ label }>
+ { items.map( ( item ) => (
+ <a
+ className={
+ item.active
+ ? 'wc-settings-ui-shell__tab is-active'
+ : 'wc-settings-ui-shell__tab'
+ }
+ aria-current={ item.active ? 'page' : undefined }
+ href={ item.href }
+ key={ item.id }
+ >
+ { item.label }
+ </a>
+ ) ) }
+ </nav>
+ ) : null;
+
const ShellHeader = ( {
schema,
context,
@@ -367,56 +387,14 @@ const ShellHeader = ( {
) : null }
{ hasNavigation ? (
<div className="wc-settings-ui-shell__navigation">
- { shell.navigation && shell.navigation.length > 0 ? (
- <nav
- className="wc-settings-ui-shell__tabs"
- aria-label={ __( 'Settings pages', 'woocommerce' ) }
- >
- { shell.navigation.map( ( item ) => (
- <a
- className={
- item.active
- ? 'wc-settings-ui-shell__tab is-active'
- : 'wc-settings-ui-shell__tab'
- }
- aria-current={
- item.active ? 'page' : undefined
- }
- href={ item.href }
- key={ item.id }
- >
- { item.label }
- </a>
- ) ) }
- </nav>
- ) : null }
- { shell.sectionNavigation &&
- shell.sectionNavigation.length > 0 ? (
- <nav
- className="wc-settings-ui-shell__tabs"
- aria-label={ __(
- 'Settings sections',
- 'woocommerce'
- ) }
- >
- { shell.sectionNavigation.map( ( item ) => (
- <a
- className={
- item.active
- ? 'wc-settings-ui-shell__tab is-active'
- : 'wc-settings-ui-shell__tab'
- }
- aria-current={
- item.active ? 'page' : undefined
- }
- href={ item.href }
- key={ item.id }
- >
- { item.label }
- </a>
- ) ) }
- </nav>
- ) : null }
+ <SettingsNavigation
+ items={ shell.navigation }
+ label={ __( 'Settings pages', 'woocommerce' ) }
+ />
+ <SettingsNavigation
+ items={ shell.sectionNavigation }
+ label={ __( 'Settings sections', 'woocommerce' ) }
+ />
{ NavigationComponent ? (
<NavigationComponent
values={ values }
@@ -510,25 +488,6 @@ export const SettingsUIPage = ( {
[ allowNavigation ]
);
- const setValues = useCallback(
- ( nextValues: Partial< SettingsValues > ) => {
- setValuesState( ( currentValues ) => {
- const mergedValues: SettingsValues = { ...currentValues };
-
- Object.entries( nextValues ).forEach(
- ( [ fieldId, value ] ) => {
- if ( typeof value !== 'undefined' ) {
- mergedValues[ fieldId ] = value;
- }
- }
- );
-
- return mergedValues;
- } );
- },
- []
- );
-
const handleCustomSave = useCallback( async () => {
if ( saveStrategy.adapter !== 'custom' ) {
return false;
@@ -623,8 +582,7 @@ export const SettingsUIPage = ( {
// The classic section links render outside the shell on top-level pages.
! target.closest(
'.wc-settings-ui-shell, #mainform .subsubsub'
- ) ||
- ! shouldPromptForNavigation( event )
+ )
) {
return;
}
@@ -700,23 +658,29 @@ export const SettingsUIPage = ( {
);
const handleDataFormChange = useCallback(
( nextValues: Record< string, SettingsValue | undefined > ) => {
- const merged: Partial< SettingsValues > = {};
-
- Object.entries( nextValues ).forEach( ( [ fieldId, value ] ) => {
- const fieldType = fieldsById.get( fieldId )?.type;
- const emptyValue =
- fieldType === 'number' ||
- fieldType === 'integer' ||
- fieldType === 'datetime-local'
- ? null
- : '';
- merged[ fieldId ] =
- typeof value === 'undefined' ? emptyValue : value;
+ setValuesState( ( currentValues ) => {
+ const mergedValues = { ...currentValues };
+ Object.entries( nextValues ).forEach(
+ ( [ fieldId, value ] ) => {
+ const field = fieldsById.get( fieldId );
+ if ( ! field ) {
+ return;
+ }
+ const fieldType = field.type;
+ const emptyValue =
+ fieldType === 'number' ||
+ fieldType === 'integer' ||
+ fieldType === 'datetime-local'
+ ? null
+ : '';
+ mergedValues[ fieldId ] =
+ value === undefined ? emptyValue : value;
+ }
+ );
+ return mergedValues;
} );
-
- setValues( merged );
},
- [ fieldsById, setValues ]
+ [ fieldsById ]
);
const formPostFields =
diff --git a/packages/js/settings-ui/src/test/dataform-adapter.test.tsx b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
index 2958415481b..346e50dcd24 100644
--- a/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
+++ b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
@@ -257,6 +257,32 @@ describe( 'dataform adapter', () => {
expect( container.textContent ).toBe( 'A link.' );
} );
+ it( 'preserves disabled explanations as text alongside sanitized help', () => {
+ const settingsField: SettingsUIField = {
+ id: 'disabled',
+ label: 'Disabled',
+ type: 'text',
+ disabled: true,
+ description: 'Read <strong>this</strong>.',
+ customAttributes: {
+ 'disabled-tooltip':
+ '<img src=x onerror=alert(1)> unavailable',
+ },
+ };
+ const field = buildDataFormField(
+ settingsField,
+ createOptions( [ settingsField ] )
+ );
+ const { container } = renderElement( <>{ field.description }</> );
+ expect( container.textContent ).toBe(
+ 'Read this. <img src=x onerror=alert(1)> unavailable'
+ );
+ expect( container.querySelector( 'strong' )?.textContent ).toBe(
+ 'this'
+ );
+ expect( container.querySelector( 'img' ) ).toBeNull();
+ } );
+
it( 'strips group descriptions to plain text', () => {
const schema: SettingsUISchema = {
id: 'test-page',
diff --git a/packages/js/settings-ui/src/test/html-rendering.test.tsx b/packages/js/settings-ui/src/test/html-rendering.test.tsx
index e168b6b111a..6c541dc3a9e 100644
--- a/packages/js/settings-ui/src/test/html-rendering.test.tsx
+++ b/packages/js/settings-ui/src/test/html-rendering.test.tsx
@@ -435,6 +435,106 @@ describe( 'settings HTML rendering', () => {
container.remove();
} );
+ it.each( [ '_self', '_SELF', '_Self' ] )(
+ 'prompts before navigation with target "%s" while settings are dirty',
+ ( target ) => {
+ const schema = createSingleFieldSchema(
+ { id: 'name', label: 'Name', type: 'text', value: 'Initial' },
+ {
+ save: { adapter: 'form_post' },
+ shell: {
+ navigation: [
+ { id: 'next', label: 'Next', href: '#next' },
+ ],
+ },
+ }
+ );
+ const { container, root } = renderElement(
+ <SettingsUIPage schema={ schema } />
+ );
+ try {
+ const input = container.querySelector( 'input' )!;
+ const link = container.querySelector( 'a' )!;
+ link.target = target;
+ act( () => changeTextInput( input, 'Changed' ) );
+ let intercepted: boolean | undefined;
+ link.addEventListener( 'click', ( event ) => {
+ intercepted = event.defaultPrevented;
+ event.preventDefault();
+ } );
+ act( () => link.click() );
+ expect( intercepted ).toBe( true );
+ expect(
+ document.querySelector(
+ '.wc-settings-ui__unsaved-changes-modal'
+ )
+ ).not.toBeNull();
+ } finally {
+ act( () => root.unmount() );
+ container.remove();
+ }
+ }
+ );
+
+ it.each( [ '', 'settings.csv' ] )(
+ 'allows a download with attribute "%s" while settings are dirty',
+ ( download ) => {
+ const schema = createSingleFieldSchema(
+ { id: 'name', label: 'Name', type: 'text', value: 'Initial' },
+ {
+ save: { adapter: 'form_post' },
+ shell: {
+ navigation: [
+ { id: 'export', label: 'Export', href: '#export' },
+ ],
+ },
+ }
+ );
+ const { container, root } = renderElement(
+ <SettingsUIPage schema={ schema } />
+ );
+ const input = container.querySelector(
+ 'input:not([type="hidden"])'
+ );
+ if ( ! ( input instanceof window.HTMLInputElement ) ) {
+ throw new Error( 'Expected an editable input.' );
+ }
+ const link = container.querySelector( 'a' )!;
+ link.setAttribute( 'download', download );
+ act( () => changeTextInput( input, 'Changed' ) );
+
+ const click = new window.MouseEvent( 'click', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ } );
+ let intercepted: boolean | undefined;
+ link.addEventListener(
+ 'click',
+ ( event ) => {
+ intercepted = event.defaultPrevented;
+ event.preventDefault();
+ },
+ { once: true }
+ );
+ act( () => {
+ link.dispatchEvent( click );
+ } );
+ expect( intercepted ).toBe( false );
+ expect(
+ document.querySelector(
+ '.wc-settings-ui__unsaved-changes-modal'
+ )
+ ).toBeNull();
+ const unload = new Event( 'beforeunload', { cancelable: true } );
+ window.dispatchEvent( unload );
+ expect( unload.defaultPrevented ).toBe( true );
+
+ act( () => root.unmount() );
+ container.remove();
+ }
+ );
+
it( 'prompts before navigating away with unsaved changes', () => {
const schema: SettingsUISchema = {
id: 'test-page',
@@ -956,6 +1056,65 @@ describe( 'settings HTML rendering', () => {
}
} );
+ it( 'excludes unknown control fields from custom save payloads', async () => {
+ const saveHandler = jest.fn().mockResolvedValue( undefined );
+ registerSettingsExtension( {
+ scope: { page: 'test-page' },
+ components: {
+ 'test/custom-field': ( { field, onChange } ) => (
+ <button
+ type="button"
+ onClick={ () =>
+ onChange( {
+ [ field.id ]: 'changed',
+ unknown: 'stray',
+ } )
+ }
+ >
+ Change fields
+ </button>
+ ),
+ },
+ saveHandlers: { 'test/save': saveHandler },
+ } );
+ const schema = createSingleFieldSchema(
+ {
+ id: 'name',
+ label: 'Name',
+ type: 'text',
+ value: 'initial',
+ component: 'test/custom-field',
+ },
+ { save: { adapter: 'custom', handler: 'test/save' } }
+ );
+ const { container, root } = renderElement(
+ <SettingsUIPage schema={ schema } />
+ );
+ try {
+ act( () => container.querySelector( 'button' )!.click() );
+ const saveButton = container.querySelector(
+ '.woocommerce-save-button'
+ );
+ if ( ! ( saveButton instanceof window.HTMLButtonElement ) ) {
+ throw new Error( 'Expected a save button.' );
+ }
+ await act( async () => {
+ saveButton.click();
+ } );
+ expect( saveHandler ).toHaveBeenCalledTimes( 1 );
+ expect( saveHandler.mock.calls[ 0 ][ 0 ] ).toEqual(
+ expect.objectContaining( {
+ values: { name: 'changed' },
+ changedValues: { name: 'changed' },
+ dirtyFields: [ 'name' ],
+ } )
+ );
+ } finally {
+ act( () => root.unmount() );
+ container.remove();
+ }
+ } );
+
it.each( [ 'number', 'integer', 'datetime-local' ] )(
'keeps a cleared %s field canonical as null',
( fieldType ) => {
diff --git a/packages/js/settings-ui/src/test/registry.test.ts b/packages/js/settings-ui/src/test/registry.test.ts
index 69986eb836b..189682059de 100644
--- a/packages/js/settings-ui/src/test/registry.test.ts
+++ b/packages/js/settings-ui/src/test/registry.test.ts
@@ -91,6 +91,115 @@ describe( 'settings extension registry', () => {
).toBe( fieldOverride );
} );
+ it( 'replaces overlapping entries without dropping unrelated registrations', () => {
+ const original = () => null;
+ const replacement = () => null;
+ const unrelated = () => null;
+ const visibility = () => false;
+ const saveHandler = () => undefined;
+ const scope = { page: 'products', section: 'inventory' };
+ const warnSpy = jest
+ .spyOn( console, 'warn' )
+ .mockImplementation( () => undefined );
+ registerSettingsExtension( {
+ scope,
+ components: { shared: original, unrelated },
+ saveHandlers: { persist: saveHandler },
+ } );
+ registerSettingsExtension( {
+ scope,
+ fieldVisibility: { stock: visibility },
+ } );
+ expect( warnSpy ).not.toHaveBeenCalled();
+ registerSettingsExtension( {
+ scope,
+ components: { shared: replacement },
+ } );
+ expect( warnSpy ).toHaveBeenCalledTimes( 1 );
+ expect( warnSpy ).toHaveBeenCalledWith(
+ expect.stringContaining( 'Replacing the conflicting entries.' ),
+ expect.any( Object )
+ );
+
+ const field = { id: 'field', label: 'Field', type: 'text' };
+ expect(
+ resolveFieldComponent( { ...field, component: 'shared' }, scope )
+ ).toBe( replacement );
+ expect(
+ resolveFieldComponent( { ...field, component: 'unrelated' }, scope )
+ ).toBe( unrelated );
+ expect( resolveSaveHandler( 'persist', scope ) ).toBe( saveHandler );
+ expect( resolveFieldVisibilityPredicate( 'stock', scope ) ).toBe(
+ visibility
+ );
+ } );
+
+ it( 'preserves unrelated entry precedence when replacing a page-wide control', () => {
+ const original = () => null;
+ const sectionControl = () => null;
+ const replacement = () => null;
+ const components = Object.freeze( {
+ shared: original,
+ other: original,
+ } );
+ jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
+ registerSettingsExtension(
+ Object.freeze( {
+ scope: { page: 'products' },
+ components,
+ } )
+ );
+ registerSettingsExtension( {
+ scope: { page: 'products', section: 'inventory' },
+ components: { shared: sectionControl, other: sectionControl },
+ } );
+ registerSettingsExtension( {
+ scope: { page: 'products' },
+ components: { shared: replacement },
+ } );
+
+ const field = { id: 'field', label: 'Field', type: 'text' };
+ const context = { page: 'products', section: 'inventory' };
+ expect(
+ resolveFieldComponent( { ...field, component: 'shared' }, context )
+ ).toBe( replacement );
+ expect(
+ resolveFieldComponent( { ...field, component: 'other' }, context )
+ ).toBe( sectionControl );
+ expect(
+ resolveFieldComponent(
+ { ...field, component: 'other' },
+ { page: 'products', section: 'shipping' }
+ )
+ ).toBe( original );
+ expect( components.shared ).toBe( original );
+ } );
+
+ it( 'stops scanning fully superseded registrations', () => {
+ const scope = {
+ get page() {
+ return 'products';
+ },
+ };
+ const pageSpy = jest.spyOn( scope, 'page', 'get' );
+ jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
+ registerSettingsExtension( {
+ scope,
+ fieldVisibility: { stock: () => false },
+ } );
+ for ( let i = 0; i < 10; i++ ) {
+ registerSettingsExtension( {
+ scope: { page: 'products' },
+ fieldVisibility: { stock: () => true },
+ } );
+ }
+ pageSpy.mockClear();
+ expect(
+ resolveFieldVisibilityPredicate( 'missing', { page: 'products' } )
+ ).toBeUndefined();
+ expect( pageSpy ).not.toHaveBeenCalled();
+ } );
+
it( 'falls back to number type renderers for promoted integer fields', () => {
const numberRenderer: SettingsEditControl = () => null;
const integerRenderer: SettingsEditControl = () => null;
diff --git a/plugins/woocommerce/changelog/refactor-bounded-settings-ui-cleanup b/plugins/woocommerce/changelog/refactor-bounded-settings-ui-cleanup
new file mode 100644
index 00000000000..a8d8d561794
--- /dev/null
+++ b/plugins/woocommerce/changelog/refactor-bounded-settings-ui-cleanup
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Document Settings UI registration replacement and existing save adapter choices.