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.