Commit fe2a6a77d04 for woocommerce

commit fe2a6a77d04172b8dce3a571b4d576a82c70bf5e
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Wed Sep 2 13:18:16 2026 +0100

    Render the settings page through the DataForm adapter (#67848)

    * Render the settings page through the DataForm adapter

    The settings page resolved each field to its own Woo renderer and kept
    grouping and visibility in page code, which meant a parallel form
    system next to DataForm. With the adapter in place, the page can hand
    form behaviour to the package.

    SettingsUIPage now renders one DataForm from the adapter's field list
    and form config. Save is unchanged: edits flow into the page values,
    the hidden inputs serialise them, and the classic form_post path saves
    them. The unsaved-changes prompt, save notices, and shell are untouched.

    Registered settings components now receive the DataForm control props
    instead of the previous component contract. The package is experimental
    and the launch contract is the fixed target, so there is no bridge for
    the old shape; components attach directly as the field's Edit control
    and are typed against a frozen Woo-owned subset of the package props. A
    field declaring an unregistered component fails closed through the page
    error boundary.

    Field descriptions render as sanitised HTML through the package
    description slot, so links in descriptions survive. Group descriptions
    become plain text, the string the form config accepts. customAttributes
    no longer reach inputs; browser-level min/max/step hints return when
    validation becomes a schema concept.

    The dataviews /wp entry ships as ESM, which jest cannot parse, so the
    jest config maps it to the package's CommonJS root build of the same
    version.

    Refs WOOPRD-3596

    * Complete the DataForm extension contract

    * Remove the unused field component rendering resolver

    The DataForm adapter resolves registered controls and fails closed
    itself, so resolveFieldComponentForRendering no longer has a caller.
    It was module-internal (never exported from the package index), and
    its fallback and fail-closed behaviour is covered by the adapter and
    page rendering tests.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * test: cover renderer resolution for unknown field types

    The adapter resolves a registered component, field override or type
    renderer before falling back to its own type descriptors, and throws
    when none of them apply. Two paths through that logic had no test.

    An unresolved type carrying options is the sharper one: DataForm
    resolves its adaptiveSelect control from elements alone, so without
    the throw the field would render as a working select rather than
    failing. The mirror case matters just as much, since an extension
    type renderer arrives without a descriptor too and has to keep both
    its control and its options.

    Cover both, plus the page-level error boundary for a type nothing can
    draw, so the fail-closed contract holds where it is observable.

    Refs WOOPRD-3596

    * fix(settings-ui): track the renamed jest config in CI

    The package's CI test job lists the files whose changes trigger it. The
    jest config moved from jest.config.json to jest.config.js in this branch,
    but the CI list still named the old file, so a change to the config alone
    would not run the JavaScript tests.

    Point the list at jest.config.js.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * test(settings-ui): count info label occurrences without a regex

    The info label test counted matches with a regular expression where the
    expected text is a fixed literal. Split on the literal instead, per the
    coding guideline that prefers a built-in over a regex.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * docs: replace the remaining native renderer mentions

    The Woo-owned renderers are gone and the docs now describe DataForm's
    built-in controls, but three fail-closed sentences still referred to a
    native renderer.

    Refs WOOPRD-3597

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg
    (cherry picked from commit 1865e6493455709d09b9109883a45f5981c19d96)

    * fix(settings-ui): fail closed at render time for unresolvable fields

    The adapter threw for an unregistered component or unsupported type while
    building every field, before any visibility filtering. The classic page this
    replaces filtered by visibility first, so a hidden field with a broken config
    was harmless there and took the whole page down here.

    Unresolvable fields now carry a control that throws when DataForm renders it.
    DataForm only renders visible fields, so a hidden broken field stays off the
    page and a visible one still reaches the error boundary.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * feat(settings-ui): expose isDisabled to registered controls

    DataForm passes the normalized field to every control, so the disabled
    state was already there at runtime, but the frozen field type hid it and
    nothing told an extension a disabled field could exist.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * test(settings-ui): cover built-in control edits reaching hidden inputs

    Only the custom control test read a hidden input back after an edit.
    Checkbox, select, number and array controls each serialize a different
    value shape, so this pins each one through a mounted page.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * docs: note that unresolvable fields fail closed instead of warning

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * chore(settings-ui): note the CJS jest mapping matches the shipped build

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * test(e2e): cover DataForm saves, visibility and registered control edits

    The registered component fixture still read props.value and called
    onChange with a string, so it rendered blank under the DataForm control
    contract while the spec only checked its label. The spec also had no
    save round-trip or visibility coverage, and pinned the General section
    to an exact field count that any unrelated schema change would break.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * fix: keep the classic settings script off the Settings UI Save button

    The classic settings script enables the Save button on any change event
    and whenever form controls are added to or removed from the settings
    form. Visibility rules add and remove React-rendered controls inside that
    form, so a checkbox toggled off and back on left the Save button enabled
    after React had disabled it.

    The Settings UI page owns its dirty state and unsaved-changes prompt, so
    the classic tracking now stays off on that page.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * test(e2e): restore the weight unit even when the save assertions fail

    The round-trip test saves a site-wide option, and the restore ran only
    after the assertions passed, so a failure leaked the changed unit into
    later tests. The restore now runs through wp-cli in a finally block.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    * test(e2e): skip plugins when restoring the weight unit through wp-cli

    The restore ran wp-cli with every plugin loaded, and the extensions
    earlier serial specs install exhausted the CLI container's memory before
    the command ran, so the cleanup itself failed the test.

    Refs WOOPRD-3596

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015D8kcBNBYzsRvGjLDWQ8xg

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/docs/extensions/settings-and-config/registering-settings-ui-components.md b/docs/extensions/settings-and-config/registering-settings-ui-components.md
index aa09ba12620..890e5537b50 100644
--- a/docs/extensions/settings-and-config/registering-settings-ui-components.md
+++ b/docs/extensions/settings-and-config/registering-settings-ui-components.md
@@ -8,9 +8,9 @@ sidebar_position: 8

 > **The settings UI is experimental** and subject to change. See the [settings UI status](./settings-ui.md#status) for details.

-Use custom components when a WooCommerce settings field needs plugin-specific React UI that cannot be represented by a native field type.
+Use custom components when a WooCommerce settings field needs plugin-specific React UI that cannot be represented by a built-in DataForm field type.

-For most fields, prefer the native renderer. Custom components are best for specialized selectors, previews, or validation flows.
+For most fields, prefer DataForm's built-in controls. Custom components are best for specialized selectors, previews, or validation flows.

 ## PHP field metadata

@@ -54,49 +54,58 @@ Registrations are scoped by settings page and, optionally, by section. This prev

 ## Component props

-Custom components receive stable field props:
+Custom components receive a stable subset of the DataForm edit-control props:

 ```ts
-type SettingsFieldComponentProps = {
+type SettingsEditControlProps = {
+	data: Record< string, string | number | boolean | string[] | null >;
 	field: {
 		id: string;
-		label: string;
-		type: string;
-		description?: string;
-		value?: string | number | boolean | string[] | null;
-		options?: Array< { label: string; value: string } >;
-		component?: string;
+		label?: string;
+		description?: string | JSX.Element;
 		placeholder?: string;
-		disabled?: boolean;
-		customAttributes?: Record< string, string | number | boolean >;
-	};
-	value: string | number | boolean | string[] | null;
-	onChange: ( value: string | number | boolean | string[] | null ) => void;
-	context: {
-		page: string;
-		section?: string;
+		elements?: Array< { label: string; value: string } >;
+		getValue: ( args: {
+			item: Record< string, string | number | boolean | string[] | null >;
+		} ) => string | number | boolean | string[] | null;
+		isDisabled: ( args: {
+			item: Record< string, string | number | boolean | string[] | null >;
+		} ) => boolean;
 	};
+	onChange: (
+		value: Partial<
+			Record< string, string | number | boolean | string[] | null >
+		>
+	) => void;
+	hideLabelFromVision?: boolean;
 };
 ```

-Call `onChange()` with the next field value. The settings UI handles hidden input serialization for the field's save adapter.
+Read the current value with `field.getValue( { item: data } )`. Call
+`onChange()` with an object containing the changed field value. The settings UI
+handles hidden input serialization for the field's save adapter.
+
+Check `field.isDisabled( { item: data } )` and render the control disabled when
+it returns `true`. DataForm applies the disabled state to its own controls only,
+so a registered component has to honor it itself.

 ## Example component

 ```tsx
-import type { SettingsFieldComponentProps } from '@woocommerce/settings-ui';
+import type { SettingsEditControlProps } from '@woocommerce/settings-ui';

 export const PaymentMethodPicker = ( {
+	data,
 	field,
-	value,
 	onChange,
-}: SettingsFieldComponentProps ) => {
+}: SettingsEditControlProps ) => {
+	const value = field.getValue( { item: data } );
 	const selectedValues = Array.isArray( value ) ? value : [];

 	return (
 		<fieldset>
 			<legend>{ field.label }</legend>
-			{ field.options?.map( ( option ) => {
+			{ field.elements?.map( ( option ) => {
 				const checked = selectedValues.includes( option.value );

 				return (
@@ -105,14 +114,14 @@ export const PaymentMethodPicker = ( {
 							type="checkbox"
 							checked={ checked }
 							onChange={ () => {
-								onChange(
-									checked
+								onChange( {
+									[ field.id ]: checked
 										? selectedValues.filter(
 												( item ) =>
 													item !== option.value
 										  )
-										: [ ...selectedValues, option.value ]
-								);
+										: [ ...selectedValues, option.value ],
+								} );
 							} }
 						/>
 						{ option.label }
@@ -163,10 +172,11 @@ Resolution order is:
 1. `field.component`
 2. `fieldOverrides[ field.id ]`
 3. `typeRenderers[ field.type ]`
+4. DataForm's built-in control

-If one registry entry is missing, resolution continues to the next registry entry. When a field declares `field.component`, that metadata states that a custom control is required. If no named component, field override, or type renderer resolves it, the page fails closed instead of silently replacing the required control with a native field.
+If one registry entry is missing, resolution continues to the next registry entry. When a field declares `field.component`, that metadata states that a custom control is required. If no named component, field override, or type renderer resolves it, the page fails closed instead of silently replacing the required control with a built-in one.

-For a field without `field.component`, the native field renderer is the final fallback after field overrides and type renderers.
+For a field without `field.component`, DataForm's built-in control for the field type is the final fallback after field overrides and type renderers. A field type with no registered renderer and no built-in control fails closed.

 ## Enqueue the component script

@@ -206,4 +216,4 @@ WooCommerce loads the settings UI package first, then your script, then mounts t

 WooCommerce validates server-observable schema metadata and declared script handles before rendering the Settings UI mount. An invalid schema or a script handle that is not registered and enqueued renders the complete classic settings page in the same response.

-PHP cannot inspect the component registry in the browser. The Settings UI fails closed when an explicitly required component has no registry fallback, when a field without an explicit component has no registered or native renderer, or when a component throws while rendering. It renders no editable fallback control and no Save action. The error notice offers a **Use classic settings** action that reloads the same page and section with `wc_settings_ui=classic` for that request. The action does not disable the feature flag, persist a preference, or reload automatically.
+PHP cannot inspect the component registry in the browser. The Settings UI fails closed when an explicitly required component has no registry fallback, when a field without an explicit component has no registered or built-in control, or when a component throws while rendering. It renders no editable fallback control and no Save action. The error notice offers a **Use classic settings** action that reloads the same page and section with `wc_settings_ui=classic` for that request. The action does not disable the feature flag, persist a preference, or reload automatically.
diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index 04b829747b5..a68e6aa1692 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -262,7 +262,7 @@ WooCommerce validates the schema structure and declared script handles on the se

 Each declared script handle must be a non-empty string, and the script must be registered and enqueued before the Settings UI renders. WooCommerce trims surrounding whitespace and removes duplicate handles before loading them; whitespace-only handles are invalid. If the schema or a declared handle is invalid, WooCommerce renders the complete classic settings page in that response. PHP cannot inspect the JavaScript component registry. Extension-defined field types remain valid when their values use the Settings UI value contract and a matching `typeRenderers` entry renders them in the browser.

-The component registry exists only in the browser, after PHP has selected the Settings UI mount. The browser resolves a named component, a field override, and then a type renderer. A field without an explicit `component` can then use a native renderer. When a field declares `component`, that custom control is required: if no registry entry resolves it, the page fails closed instead of silently replacing it with a native field. A field without an explicit component also fails closed when it has no registered or native renderer. Component render errors use the same fail-closed state.
+The component registry exists only in the browser, after PHP has selected the Settings UI mount. The browser resolves a named component, a field override, and then a type renderer. A field without an explicit `component` can then use a built-in DataForm control. When a field declares `component`, that custom control is required: if no registry entry resolves it, the page fails closed instead of silently replacing it with a built-in control. A field without an explicit component also fails closed when it has no registered or built-in control. Component render errors use the same fail-closed state.

 The fail-closed state has no editable fallback and no Save action. Its error notice provides a **Use classic settings** link that preserves the current page and section and adds `wc_settings_ui=classic`. This is a user-initiated, request-only reload: it does not change the feature flag or automatically reload the page.

@@ -288,32 +288,22 @@ array(
 )
 ```

-## Rich group descriptions and actions
+## Group descriptions

-Group title rows can include sanitized description markup and structured header actions. Use this for contextual links such as documentation or secondary actions that belong to the whole group, rather than creating a display-only custom field.
+Group title rows can include a plain-text description. Use it for short context
+that applies to the whole group.

 ```php
 array(
-	'id'      => 'my_plugin_checkout',
-	'type'    => 'title',
-	'title'   => __( 'Checkout experience', 'my-plugin' ),
-	'desc'    => sprintf(
-		/* translators: %s: documentation link */
-		__( 'Choose where customers can use express payment methods. %s', 'my-plugin' ),
-		'<a href="' . esc_url( 'https://example.com/docs' ) . '">' . esc_html__( 'Learn more', 'my-plugin' ) . '</a>'
-	),
-	'actions' => array(
-		array(
-			'id'      => 'manage',
-			'label'   => __( 'Manage locations', 'my-plugin' ),
-			'href'    => admin_url( 'admin.php?page=wc-settings&tab=shipping' ),
-			'variant' => 'secondary',
-		),
-	),
+	'id'    => 'my_plugin_checkout',
+	'type'  => 'title',
+	'title' => __( 'Checkout experience', 'my-plugin' ),
+	'desc'  => __( 'Choose where customers can use express payment methods.', 'my-plugin' ),
 )
 ```

-Descriptions are sanitized with `wp_kses_post()`. Actions are structured data with `id`, `label`, `href`, optional `variant`, optional `target`, and optional `rel`.
+Group descriptions render as plain text. Put links and other sanitized HTML in
+a field description instead.

 ## Page header

@@ -360,6 +350,6 @@ In development, the settings UI logs warnings for common integration issues:

 -   The settings payload is missing.
 -   The `wc-settings-ui` script is missing for a settings UI mount.
--   A field declares a component that is not registered.
--   A field type has no registered or native renderer.
 -   A field declares an unknown save adapter.
+
+A field that declares an unregistered component, or whose type has no registered or built-in control, does not log a warning. The page fails closed instead, as described under [Load extension scripts before mount](#load-extension-scripts-before-mount).
diff --git a/packages/js/settings-ui/changelog/update-wooprd-3596-dataform-renderer b/packages/js/settings-ui/changelog/update-wooprd-3596-dataform-renderer
new file mode 100644
index 00000000000..3a567e16b18
--- /dev/null
+++ b/packages/js/settings-ui/changelog/update-wooprd-3596-dataform-renderer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Render Settings UI pages through DataForm. Registered settings components now receive the DataForm control props.
diff --git a/packages/js/settings-ui/jest.config.js b/packages/js/settings-ui/jest.config.js
new file mode 100644
index 00000000000..24b892a9524
--- /dev/null
+++ b/packages/js/settings-ui/jest.config.js
@@ -0,0 +1,14 @@
+const preset = require( './node_modules/@woocommerce/internal-js-tests/jest-preset.js' );
+
+module.exports = {
+	rootDir: './',
+	roots: [ '<rootDir>/src' ],
+	preset: './node_modules/@woocommerce/internal-js-tests/jest-preset.js',
+	moduleNameMapper: {
+		// The `/wp` entry ships as ESM, which jest cannot parse. The package
+		// root is the same 17.1.0 source built as CommonJS, so tests exercise
+		// the same DataForm; the e2e suite covers the shipped `/wp` bundle.
+		'^@wordpress/dataviews/wp$': require.resolve( '@wordpress/dataviews' ),
+		...preset.moduleNameMapper,
+	},
+};
diff --git a/packages/js/settings-ui/jest.config.json b/packages/js/settings-ui/jest.config.json
deleted file mode 100644
index 51e32138299..00000000000
--- a/packages/js/settings-ui/jest.config.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-	"rootDir": "./",
-	"roots": [ "<rootDir>/src" ],
-	"preset": "./node_modules/@woocommerce/internal-js-tests/jest-preset.js"
-}
diff --git a/packages/js/settings-ui/package.json b/packages/js/settings-ui/package.json
index 640751e3d79..5963fb192af 100644
--- a/packages/js/settings-ui/package.json
+++ b/packages/js/settings-ui/package.json
@@ -89,7 +89,7 @@
 		"lint:lang:js": "eslint src",
 		"lint:lang:types": "tsc --build --emitDeclarationOnly",
 		"prepack": "pnpm build:publish:project",
-		"test:js": "jest --config ./jest.config.json --passWithNoTests",
+		"test:js": "jest --config ./jest.config.js --passWithNoTests",
 		"watch:build": "pnpm watch:build:project",
 		"watch:build:project": "pnpm --stream '/^watch:build:project:.*$/'",
 		"watch:build:project:esm": "node build.mjs --watch"
@@ -105,7 +105,7 @@
 					"name": "JavaScript",
 					"command": "test:js",
 					"changes": [
-						"jest.config.json",
+						"jest.config.js",
 						"tsconfig.json",
 						"src/**/*.{js,jsx,ts,tsx}"
 					],
diff --git a/packages/js/settings-ui/src/dataform-adapter.tsx b/packages/js/settings-ui/src/dataform-adapter.tsx
index c992bb510f2..420f2d8ff6e 100644
--- a/packages/js/settings-ui/src/dataform-adapter.tsx
+++ b/packages/js/settings-ui/src/dataform-adapter.tsx
@@ -13,9 +13,10 @@ import type {
 /**
  * Internal dependencies
  */
-import { error, warn } from './diagnostics';
-import { createSettingsHelpElement } from './html';
+import { error } from './diagnostics';
+import { createSettingsHelpElement, sanitizeSettingsHtml } from './html';
 import {
+	resolveFieldComponent,
 	resolveFieldVisibilityPredicate,
 	resolveGroupVisibilityPredicate,
 } from './registry';
@@ -30,8 +31,7 @@ import type {
 import { valueMatchesVisibilityRule } from './values';

 // The adapter assumes the canonical value vocabulary from the PHP schema
-// builder and how extension components attach is a renderer concern, so
-// neither value coercion nor component registry resolution happens here.
+// builder, so no value coercion happens here.

 export type DataFormAdapterOptions = {
 	schema: SettingsUISchema;
@@ -44,6 +44,17 @@ export type DataFormAdapter = {
 	getForm: ( values: SettingsValues ) => Form;
 };

+// FormField descriptions are plain strings, so group descriptions lose markup.
+const toPlainText = ( html?: string ) => {
+	if ( ! html ) {
+		return undefined;
+	}
+
+	const container = document.createElement( 'div' );
+	container.innerHTML = sanitizeSettingsHtml( html );
+	return container.textContent || undefined;
+};
+
 type SettingsTypeDescriptor = {
 	type: FieldTypeName;
 	// Only named where the type alone resolves the wrong control. DataForm
@@ -232,6 +243,14 @@ const buildValidationRules = (
 	return rules;
 };

+// The control throws when DataForm renders it, so a hidden field with a
+// broken config stays harmless while a visible one still fails closed.
+const createFailingControl =
+	( message: string ): Field< SettingsValues >[ 'Edit' ] =>
+	() => {
+		throw new Error( message );
+	};
+
 export const buildDataFormField = (
 	settingsField: SettingsUIField,
 	options: DataFormAdapterOptions
@@ -242,6 +261,10 @@ export const buildDataFormField = (
 	)
 		? settingsTypeDescriptors[ settingsField.type ]
 		: undefined;
+	const registeredComponent = resolveFieldComponent(
+		settingsField,
+		options.context
+	);

 	const field: Field< SettingsValues > = {
 		id: settingsField.id,
@@ -255,6 +278,22 @@ export const buildDataFormField = (
 		isDisabled: isFieldDisabled( settingsField ),
 	};

+	if ( registeredComponent ) {
+		// A registered control accepts a frozen subset of the DataForm control
+		// props, so the wider package props remain assignable to it.
+		field.Edit = registeredComponent as Field< SettingsValues >[ 'Edit' ];
+		return field;
+	}
+
+	// A field declaring a component requires that custom control. Failing
+	// closed beats silently rendering a built-in control in its place.
+	if ( settingsField.component ) {
+		field.Edit = createFailingControl(
+			`Component "${ settingsField.component }" is not registered.`
+		);
+		return field;
+	}
+
 	if ( settingsField.type === 'info' ) {
 		field.readOnly = true;
 		// DataForm paints the label for a read-only field and drops its
@@ -269,13 +308,13 @@ export const buildDataFormField = (
 		return field;
 	}

+	// Registered renderers resolve above, so reaching here means nothing can
+	// draw the field. Failing closed beats dropping it beside a live Save
+	// button, and matches the page this renderer replaces.
 	if ( ! descriptor ) {
-		// The renderer resolves registered type renderers before failing, so
-		// unknown types keep Edit and render unset rather than a baked
-		// fallback the adapter cannot decide on.
-		warn( `Field type "${ settingsField.type }" is not supported.`, {
-			field: settingsField,
-		} );
+		field.Edit = createFailingControl(
+			`Field type "${ settingsField.type }" is not supported.`
+		);
 		return field;
 	}

@@ -286,11 +325,12 @@ export const buildDataFormField = (
 	return field;
 };

-// Group descriptions and actions are HTML chrome that stays with the
-// renderer; FormField.description only accepts a plain string.
+// FormField.description only accepts a plain string, so a group description
+// keeps its text and loses its markup until DataForm accepts an element.
 const buildGroupFormField = ( group: SettingsUIGroup ): FormField => ( {
 	id: group.id,
 	label: group.title || undefined,
+	description: toPlainText( group.description ),
 	layout: group.title
 		? { type: 'card', isCollapsible: false }
 		: { type: 'card', withHeader: false },
diff --git a/packages/js/settings-ui/src/index.ts b/packages/js/settings-ui/src/index.ts
index 3188af34744..4b929680235 100644
--- a/packages/js/settings-ui/src/index.ts
+++ b/packages/js/settings-ui/src/index.ts
@@ -22,6 +22,9 @@ export type {
 	SettingsUIShellBadge,
 	SettingsUIShellBadgeIntent,
 	SettingsUIShellBreadcrumb,
+	SettingsEditControl,
+	SettingsEditControlField,
+	SettingsEditControlProps,
 	SettingsExtensionRegistration,
 	SettingsExtensionScope,
 	SettingsFieldComponent,
diff --git a/packages/js/settings-ui/src/registry.ts b/packages/js/settings-ui/src/registry.ts
index 58be9697b64..428c1aae09c 100644
--- a/packages/js/settings-ui/src/registry.ts
+++ b/packages/js/settings-ui/src/registry.ts
@@ -4,8 +4,8 @@
 import { warn } from './diagnostics';
 import type {
 	SettingsUIField,
+	SettingsEditControl,
 	SettingsExtensionRegistration,
-	SettingsFieldComponent,
 	SettingsFieldContext,
 	SettingsRegionComponent,
 	SettingsSaveHandler,
@@ -181,7 +181,7 @@ export const __resetRegistry = () => {
 export const resolveFieldComponent = (
 	field: SettingsUIField,
 	context: SettingsFieldContext
-): SettingsFieldComponent | undefined => {
+): SettingsEditControl | undefined => {
 	const componentName = field.component;
 	const component = componentName
 		? findInMatchingRegistrations(
@@ -215,25 +215,6 @@ export const resolveFieldComponent = (
 	return undefined;
 };

-export const resolveFieldComponentForRendering = (
-	field: SettingsUIField,
-	context: SettingsFieldContext
-): SettingsFieldComponent | undefined => {
-	const component = resolveFieldComponent( field, context );
-
-	if ( component ) {
-		return component;
-	}
-
-	if ( field.component ) {
-		throw new Error(
-			`Component "${ field.component }" is not registered.`
-		);
-	}
-
-	return undefined;
-};
-
 export const resolveFieldVisibilityPredicate = (
 	fieldId: string,
 	context: SettingsFieldContext
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index a9e3e06c581..86a13f8a1be 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -6,7 +6,6 @@ import { Button, Modal, Notice } from '@wordpress/components';
 import {
 	Component,
 	createElement,
-	RawHTML,
 	useCallback,
 	useEffect,
 	useMemo,
@@ -19,23 +18,13 @@ import type { ErrorInfo, ReactNode } from 'react';
 /**
  * Internal dependencies
  */
+import { createDataFormAdapter } from './dataform-adapter';
+import { DataForm } from './dataform-runtime';
 import { HiddenInputs } from './hidden-inputs';
-import { error, warn } from './diagnostics';
-import { sanitizeSettingsHtml } from './html';
-import {
-	isNativeSettingsFieldType,
-	NativeSettingsField,
-} from './native-fields';
-import {
-	resolveFieldComponentForRendering,
-	resolveFieldVisibilityPredicate,
-	resolveGroupVisibilityPredicate,
-	resolveRegionComponent,
-	resolveSaveHandler,
-} from './registry';
+import { error } from './diagnostics';
+import { resolveRegionComponent, resolveSaveHandler } from './registry';
 import type {
 	SettingsUIField,
-	SettingsUIGroup,
 	SettingsUIShellBadgeIntent,
 	SettingsUISaveStrategy,
 	SettingsUISchema,
@@ -43,7 +32,7 @@ import type {
 	SettingsValue,
 	SettingsValues,
 } from './types';
-import { areValuesEqual, valueMatchesVisibilityRule } from './values';
+import { areValuesEqual } from './values';

 type SaveNotice = {
 	status: 'success' | 'error';
@@ -87,14 +76,6 @@ const getChangedValues = (
 	return changedValues;
 };

-const getFieldTypeClassName = ( type: string ) =>
-	`wc-settings-ui__field--${ type.replace( /[^a-z0-9_-]/gi, '-' ) }`;
-
-const getActionVariant = ( variant?: string ) =>
-	( [ 'primary', 'secondary', 'tertiary', 'link' ].includes( variant || '' )
-		? variant
-		: 'secondary' ) as 'primary' | 'secondary' | 'tertiary' | 'link';
-
 const BADGE_INTENTS = {
 	default: true,
 	info: true,
@@ -224,89 +205,6 @@ const UnsavedChangesModal = ( {
 	);
 };

-const GroupHeader = ( { group }: { group: SettingsUIGroup } ) => {
-	const hasHeaderContent =
-		group.title || group.description || ( group.actions || [] ).length > 0;
-
-	if ( ! hasHeaderContent ) {
-		return null;
-	}
-
-	return (
-		<header className="wc-settings-ui__section-header">
-			<div className="wc-settings-ui__section-heading">
-				{ group.title ? <h2>{ group.title }</h2> : null }
-				{ group.description ? (
-					<div className="wc-settings-ui__section-description">
-						<RawHTML>
-							{ sanitizeSettingsHtml( group.description ) }
-						</RawHTML>
-					</div>
-				) : null }
-			</div>
-			{ group.actions && group.actions.length > 0 ? (
-				<div className="wc-settings-ui__section-actions">
-					{ group.actions.map( ( action ) => (
-						<Button
-							key={ action.id }
-							variant={ getActionVariant( action.variant ) }
-							href={ action.href }
-							target={ action.target }
-							rel={ action.rel }
-						>
-							{ action.label }
-						</Button>
-					) ) }
-				</div>
-			) : null }
-		</header>
-	);
-};
-
-const getVisible = ( {
-	id,
-	kind,
-	field,
-	values,
-	initialValues,
-	context,
-	schema,
-}: {
-	id: string;
-	kind: 'field' | 'group';
-	field?: SettingsUIField;
-	values: SettingsValues;
-	initialValues: SettingsValues;
-	context: SettingsFieldContext;
-	schema: SettingsUISchema;
-} ) => {
-	const predicate =
-		kind === 'field'
-			? resolveFieldVisibilityPredicate( id, context )
-			: resolveGroupVisibilityPredicate( id, context );
-
-	if ( predicate ) {
-		try {
-			return predicate( { values, initialValues, context, schema } );
-		} catch ( predicateError ) {
-			warn(
-				`Visibility predicate for ${ kind } "${ id }" failed. Rendering it visible.`,
-				{ error: predicateError, context }
-			);
-			return true;
-		}
-	}
-
-	if ( field?.visibility ) {
-		return valueMatchesVisibilityRule(
-			values[ field.visibility.controller ],
-			field.visibility.value
-		);
-	}
-
-	return true;
-};
-
 const getAllFields = ( schema: SettingsUISchema ): SettingsUIField[] =>
 	Object.values( schema.groups ).flatMap( ( group ) => group.fields );

@@ -580,16 +478,6 @@ export const SettingsUIPage = ( {
 		setPendingNavigation( null );
 	}, [ schema ] );

-	const setValue = useCallback(
-		( fieldId: string, nextValue: SettingsValue ) => {
-			setValuesState( ( currentValues ) => ( {
-				...currentValues,
-				[ fieldId ]: nextValue,
-			} ) );
-		},
-		[]
-	);
-
 	const allowNavigation = useCallback( () => {
 		allowNavigationRef.current = true;
 		clearLegacyFormPrompt();
@@ -796,35 +684,27 @@ export const SettingsUIPage = ( {
 		submitSettingsForm,
 	] );

-	const visibleGroups = useMemo(
-		() =>
-			Object.values( schema.groups )
-				.filter( ( group ) =>
-					getVisible( {
-						id: group.id,
-						kind: 'group',
-						values,
-						initialValues,
-						context,
-						schema,
-					} )
-				)
-				.map( ( group ) => ( {
-					...group,
-					fields: group.fields.filter( ( field ) =>
-						getVisible( {
-							id: field.id,
-							kind: 'field',
-							field,
-							values,
-							initialValues,
-							context,
-							schema,
-						} )
-					),
-				} ) )
-				.filter( ( group ) => group.fields.length > 0 ),
-		[ context, initialValues, schema, values ]
+	const dataFormAdapter = useMemo(
+		() => createDataFormAdapter( { schema, context, initialValues } ),
+		[ schema, context, initialValues ]
+	);
+	const dataForm = useMemo(
+		() => dataFormAdapter.getForm( values ),
+		[ dataFormAdapter, values ]
+	);
+	const handleDataFormChange = useCallback(
+		( nextValues: Record< string, SettingsValue | undefined > ) => {
+			const merged: Partial< SettingsValues > = {};
+
+			// Package controls emit undefined for a cleared value; the settings
+			// vocabulary represents that as an empty string.
+			Object.entries( nextValues ).forEach( ( [ fieldId, value ] ) => {
+				merged[ fieldId ] = typeof value === 'undefined' ? '' : value;
+			} );
+
+			setValues( merged );
+		},
+		[ setValues ]
 	);

 	const formPostFields =
@@ -881,69 +761,12 @@ export const SettingsUIPage = ( {
 				</Notice>
 			) : null }
 			<div className="wc-settings-ui">
-				{ visibleGroups.map( ( group ) => (
-					<section
-						className="wc-settings-ui__section"
-						key={ group.id }
-					>
-						<div className="wc-settings-ui__section-card">
-							<GroupHeader group={ group } />
-							<div className="wc-settings-ui__section-fields">
-								{ group.fields.map( ( field ) => {
-									const RegisteredFieldComponent =
-										resolveFieldComponentForRendering(
-											field,
-											context
-										);
-
-									if (
-										! RegisteredFieldComponent &&
-										! isNativeSettingsFieldType(
-											field.type
-										)
-									) {
-										throw new Error(
-											`Field type "${ field.type }" is not supported.`
-										);
-									}
-
-									const FieldComponent =
-										RegisteredFieldComponent ||
-										NativeSettingsField;
-									const value = values[ field.id ];
-
-									return (
-										<div
-											className={ [
-												'wc-settings-ui__field',
-												getFieldTypeClassName(
-													field.type
-												),
-											].join( ' ' ) }
-											key={ field.id }
-										>
-											<FieldComponent
-												field={ field }
-												value={ value }
-												context={ context }
-												values={ values }
-												initialValues={ initialValues }
-												setValue={ setValue }
-												setValues={ setValues }
-												onChange={ ( nextValue ) =>
-													setValue(
-														field.id,
-														nextValue
-													)
-												}
-											/>
-										</div>
-									);
-								} ) }
-							</div>
-						</div>
-					</section>
-				) ) }
+				<DataForm
+					data={ values }
+					fields={ dataFormAdapter.fields }
+					form={ dataForm }
+					onChange={ handleDataFormChange }
+				/>
 				{ ! showHeader && saveButton ? (
 					<div className="wc-settings-ui__footer-actions">
 						{ saveButton }
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 532eba63607..bb0bf9c99c2 100644
--- a/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
+++ b/packages/js/settings-ui/src/test/dataform-adapter.test.tsx
@@ -2,6 +2,7 @@
  * External dependencies
  */
 import { DataForm } from '@wordpress/dataviews';
+import type { Field } from '@wordpress/dataviews';
 import { act } from 'react';
 import { createElement } from '@wordpress/element';
 import { createRoot } from 'react-dom/client';
@@ -52,6 +53,10 @@ const textField: SettingsUIField = {
 	type: 'text',
 };

+// Unresolvable fields carry a control that throws when DataForm renders it.
+const renderEditControl = ( field: Field< SettingsValues > ) =>
+	( field.Edit as ( props: object ) => unknown )( {} );
+
 const mountedRoots: Array< () => void > = [];

 const renderElement = ( element: JSX.Element ) => {
@@ -192,50 +197,137 @@ describe( 'dataform adapter', () => {
 			expect( container.textContent ).toBe( 'Useful information.' );
 		} );

-		it( 'maps field descriptions to sanitized help elements', () => {
-			const field = buildDataFormField(
-				{
-					...textField,
-					description:
-						'See the <a href="https://woocommerce.com">docs</a>.',
-				},
-				createOptions( [] )
-			);
-
-			const { container } = renderElement( <>{ field.description }</> );
-			const link = container.querySelector( 'a' );
-			expect( link?.textContent ).toBe( 'docs' );
-			expect( container.textContent ).toBe( 'See the docs.' );
-		} );
-
 		it.each( [
 			'extension_defined',
 			'constructor',
 			'__proto__',
 			'toString',
 		] )(
-			'warns and leaves the control unset for unknown type "%s"',
+			'fails closed for unknown type "%s" with no registered renderer',
 			( type ) => {
-				const warnSpy = jest
-					.spyOn( console, 'warn' )
-					.mockImplementation( () => undefined );
 				const field = buildDataFormField(
 					{ ...textField, type },
 					createOptions( [] )
 				);

-				expect( field.type ).toBeUndefined();
-				expect( field.Edit ).toBeUndefined();
-				expect( field.render ).toBeUndefined();
-				expect( field.readOnly ).toBeUndefined();
-				expect( warnSpy ).toHaveBeenCalledWith(
-					expect.stringContaining(
-						`Field type "${ type }" is not supported.`
-					),
-					expect.any( Object )
+				expect( () => renderEditControl( field ) ).toThrow(
+					`Field type "${ type }" is not supported.`
 				);
 			}
 		);
+
+		it( 'fails closed for an unknown type that carries options', () => {
+			// Options alone resolve DataForm's adaptiveSelect control, so an
+			// unresolved type has to fail before that fallback applies.
+			const field = buildDataFormField(
+				{
+					...textField,
+					type: 'extension_defined',
+					options: [ { label: 'One', value: 'one' } ],
+				},
+				createOptions( [] )
+			);
+
+			expect( () => renderEditControl( field ) ).toThrow(
+				'Field type "extension_defined" is not supported.'
+			);
+		} );
+	} );
+
+	describe( 'descriptions and components', () => {
+		it( 'maps field descriptions to sanitized help elements', () => {
+			const field = buildDataFormField(
+				{
+					...textField,
+					description:
+						'A <a href="https://example.com">link</a><script>alert("x")</script>.',
+				},
+				createOptions( [] )
+			);
+
+			const { container } = renderElement( <>{ field.description }</> );
+			expect( container.querySelector( 'a' )?.textContent ).toBe(
+				'link'
+			);
+			expect( container.querySelector( 'script' ) ).toBeNull();
+			expect( container.textContent ).toBe( 'A link.' );
+		} );
+
+		it( 'strips group descriptions to plain text', () => {
+			const schema: SettingsUISchema = {
+				id: 'test-page',
+				groups: {
+					general: {
+						id: 'general',
+						title: 'General',
+						description: 'Configure <strong>the basics</strong>.',
+						fields: [ textField ],
+					},
+				},
+			};
+			const adapter = createDataFormAdapter( {
+				schema,
+				context,
+				initialValues: {},
+			} );
+
+			const [ group ] = adapter.getForm( {} ).fields as Array< {
+				description?: string;
+			} >;
+			expect( group.description ).toBe( 'Configure the basics.' );
+		} );
+
+		it( 'attaches a registered control as the field edit component', () => {
+			const Registered = () => <div>Registered control</div>;
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				components: { 'test/custom-field': Registered },
+			} );
+
+			const field = buildDataFormField(
+				{ ...textField, component: 'test/custom-field' },
+				createOptions( [] )
+			);
+
+			expect( field.Edit ).toBe( Registered );
+		} );
+
+		it( 'resolves an unknown type through a registered type renderer', () => {
+			const Registered = () => <div>Extension control</div>;
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				typeRenderers: { extension_defined: Registered },
+			} );
+
+			const field = buildDataFormField(
+				{
+					...textField,
+					type: 'extension_defined',
+					options: [ { label: 'One', value: 'one' } ],
+				},
+				createOptions( [] )
+			);
+
+			expect( field.Edit ).toBe( Registered );
+			// Extension controls keep their options; only genuinely
+			// unresolvable types fail.
+			expect( field.elements ).toEqual( [
+				{ label: 'One', value: 'one' },
+			] );
+		} );
+
+		it( 'fails closed when a declared component is not registered', () => {
+			jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
+
+			const field = buildDataFormField(
+				{ ...textField, component: 'test/missing-component' },
+				createOptions( [] )
+			);
+
+			expect( () => renderEditControl( field ) ).toThrow(
+				'Component "test/missing-component" is not registered.'
+			);
+		} );
 	} );

 	describe( 'visibility', () => {
@@ -685,6 +777,68 @@ describe( 'dataform adapter', () => {
 			).toBe( true );
 		} );

+		const brokenHiddenField: SettingsUIField = {
+			id: 'hidden_field',
+			label: 'Hidden field',
+			type: 'text',
+			component: 'test/missing-component',
+			visibility: { controller: 'toggle', value: 'on' },
+		};
+		const toggleField: SettingsUIField = {
+			id: 'toggle',
+			label: 'Toggle',
+			type: 'text',
+		};
+
+		it( 'keeps a hidden field with an unregistered component off the page', () => {
+			const options = createOptions( [ toggleField, brokenHiddenField ] );
+			const adapter = createDataFormAdapter( options );
+			const data = { toggle: 'off', hidden_field: '' };
+
+			const { container } = renderElement(
+				<DataForm
+					data={ data }
+					fields={ adapter.fields }
+					form={ adapter.getForm( data ) }
+					onChange={ () => undefined }
+				/>
+			);
+
+			expect( container.querySelectorAll( 'input' ) ).toHaveLength( 1 );
+		} );
+
+		it( 'fails closed once a field with an unregistered component is visible', () => {
+			jest.spyOn( console, 'error' ).mockImplementation(
+				() => undefined
+			);
+			const options = createOptions( [ toggleField, brokenHiddenField ] );
+			const adapter = createDataFormAdapter( options );
+			const data = { toggle: 'on', hidden_field: '' };
+			const container = document.createElement( 'div' );
+			document.body.appendChild( container );
+			const root = createRoot( container );
+
+			try {
+				expect( () =>
+					act( () => {
+						root.render(
+							<DataForm
+								data={ data }
+								fields={ adapter.fields }
+								form={ adapter.getForm( data ) }
+								onChange={ () => undefined }
+							/>
+						);
+					} )
+				).toThrow(
+					'Component "test/missing-component" is not registered.'
+				);
+			} finally {
+				act( () => root.unmount() );
+				container.remove();
+			}
+		} );
+
 		it( 'shows an info field title exactly once', () => {
 			const infoField: SettingsUIField = {
 				id: 'info_field',
@@ -774,6 +928,35 @@ describe( 'dataform adapter', () => {
 			expect( container.querySelector( 'textarea' ) ).toBeNull();
 		} );

+		it( 'renders a registered type renderer instead of the options fallback', () => {
+			const Registered = () => <div>Extension control</div>;
+			registerSettingsExtension( {
+				scope: { page: 'test-page' },
+				typeRenderers: { extension_defined: Registered },
+			} );
+			const extensionField: SettingsUIField = {
+				id: 'extension_field',
+				label: 'Extension field',
+				type: 'extension_defined',
+				options: [ { label: 'One', value: 'one' } ],
+			};
+			const options = createOptions( [ extensionField ] );
+			const adapter = createDataFormAdapter( options );
+			const data = { extension_field: 'one' };
+
+			const { container } = renderElement(
+				<DataForm
+					data={ data }
+					fields={ adapter.fields }
+					form={ adapter.getForm( data ) }
+					onChange={ () => undefined }
+				/>
+			);
+
+			expect( container.textContent ).toContain( 'Extension control' );
+			expect( container.querySelector( 'select' ) ).toBeNull();
+		} );
+
 		it( 'renders array fields as a closed multi-select', () => {
 			const arrayField: SettingsUIField = {
 				id: 'countries',
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 9bf169ef89f..3d12548a8ae 100644
--- a/packages/js/settings-ui/src/test/html-rendering.test.tsx
+++ b/packages/js/settings-ui/src/test/html-rendering.test.tsx
@@ -89,6 +89,15 @@ const changeTextInput = ( input: HTMLInputElement, value: string ) => {
 	);
 };

+const changeSelect = ( select: HTMLSelectElement, values: string[] ) => {
+	Array.from( select.options ).forEach( ( option ) => {
+		option.selected = values.includes( option.value );
+	} );
+	select.dispatchEvent(
+		new Event( 'change', { bubbles: true, cancelable: true } )
+	);
+};
+
 const getUnsavedChangesActionButton = ( label: string ): HTMLButtonElement => {
 	const button = Array.from(
 		document.body.querySelectorAll< HTMLButtonElement >(
@@ -151,21 +160,12 @@ describe( 'settings HTML rendering', () => {
 			<SettingsUIPage schema={ schema } />
 		);

+		expect( container.querySelector( '.wc-settings-ui' ) ).not.toBeNull();
 		expect(
-			container.querySelector( '.wc-settings-ui__section' )
+			container.querySelector( '.dataforms-layouts__wrapper' )
 		).not.toBeNull();
 		expect(
 			container.querySelector( '.wc-settings-ui__section-card' )
-		).not.toBeNull();
-		expect(
-			container.querySelector( '.wc-settings-ui__section-fields' )
-		).not.toBeNull();
-		expect( container.querySelector( '.wc-settings-ui__row' ) ).toBeNull();
-		expect(
-			container.querySelector( '.wc-settings-ui__group-panel' )
-		).toBeNull();
-		expect(
-			container.querySelector( '.wc-settings-ui__group-header' )
 		).toBeNull();
 		expect( container.textContent ).toContain( 'General settings' );
 		expect( container.textContent ).toContain( 'Test field' );
@@ -212,8 +212,8 @@ describe( 'settings HTML rendering', () => {
 		);

 		expect( container.textContent ).toContain( 'Default section field' );
-		expect( DefaultSectionField.mock.calls[ 0 ][ 0 ].context.section ).toBe(
-			''
+		expect( DefaultSectionField.mock.calls[ 0 ][ 0 ].field.id ).toBe(
+			'test_field'
 		);

 		act( () => root.unmount() );
@@ -469,7 +469,7 @@ describe( 'settings HTML rendering', () => {
 			<SettingsUIPage schema={ schema } />
 		);

-		const input = container.querySelector( 'input[type="text"]' );
+		const input = container.querySelector( 'input:not([type="hidden"])' );
 		const link = container.querySelector(
 			'a[href="https://example.com/next"]'
 		);
@@ -547,7 +547,9 @@ describe( 'settings HTML rendering', () => {
 		form.insertBefore( sectionLinks, container );

 		try {
-			const input = container.querySelector( 'input[type="text"]' );
+			const input = container.querySelector(
+				'input:not([type="hidden"])'
+			);
 			const link = sectionLinks.querySelector( 'a' );

 			expect( input ).toBeInstanceOf( HTMLInputElement );
@@ -615,7 +617,9 @@ describe( 'settings HTML rendering', () => {
 		);

 		try {
-			const input = container.querySelector( 'input[type="text"]' );
+			const input = container.querySelector(
+				'input:not([type="hidden"])'
+			);
 			const link = container.querySelector(
 				'a[href="https://example.com/next"]'
 			);
@@ -713,7 +717,7 @@ describe( 'settings HTML rendering', () => {
 			<SettingsUIPage schema={ schema } />
 		);

-		const input = container.querySelector( 'input[type="text"]' );
+		const input = container.querySelector( 'input:not([type="hidden"])' );
 		const link = container.querySelector(
 			'a[href="https://example.com/next"]'
 		);
@@ -817,7 +821,7 @@ describe( 'settings HTML rendering', () => {
 			<SettingsUIPage schema={ schema } />
 		);

-		const input = container.querySelector( 'input[type="text"]' );
+		const input = container.querySelector( 'input:not([type="hidden"])' );
 		const link = container.querySelector(
 			'a[href="https://example.com/next"]'
 		);
@@ -876,6 +880,246 @@ describe( 'settings HTML rendering', () => {
 		container.remove();
 	} );

+	it( 'routes registered control edits into the page values', () => {
+		registerSettingsExtension( {
+			scope: { page: 'test-page' },
+			components: {
+				'test/custom-field': ( { data, field, onChange } ) => (
+					<button
+						onClick={ () =>
+							onChange( { [ field.id ]: 'clicked' } )
+						}
+					>
+						{ `Custom control: ${ String(
+							data[ field.id ] ?? ''
+						) }` }
+					</button>
+				),
+			},
+		} );
+
+		const schema: SettingsUISchema = {
+			id: 'test-page',
+			title: 'Test page',
+			section: 'default',
+			save: { adapter: 'form_post' },
+			groups: {
+				general: {
+					id: 'general',
+					fields: [
+						{
+							id: 'test_field',
+							label: 'Test field',
+							type: 'text',
+							value: 'initial',
+							component: 'test/custom-field',
+						},
+					],
+				},
+			},
+		};
+
+		const { container, form, root } = renderElementInMainForm(
+			<SettingsUIPage schema={ schema } />
+		);
+
+		try {
+			expect( container.textContent ).toContain(
+				'Custom control: initial'
+			);
+
+			act( () => {
+				container.querySelector( 'button' )?.click();
+			} );
+
+			expect( container.textContent ).toContain(
+				'Custom control: clicked'
+			);
+			expect(
+				form.querySelector( 'input[name="test_field"]' )
+			).toHaveAttribute( 'value', 'clicked' );
+		} finally {
+			act( () => root.unmount() );
+			form.remove();
+		}
+	} );
+
+	it( 'serializes edits from built-in controls into the form-post hidden inputs', () => {
+		const schema: SettingsUISchema = {
+			id: 'test-page',
+			title: 'Test page',
+			section: 'default',
+			save: { adapter: 'form_post' },
+			groups: {
+				general: {
+					id: 'general',
+					fields: [
+						{
+							id: 'flag',
+							label: 'Flag',
+							type: 'checkbox',
+							value: false,
+						},
+						{
+							id: 'unit',
+							label: 'Unit',
+							type: 'select',
+							value: 'kg',
+							options: [
+								{ label: 'kg', value: 'kg' },
+								{ label: 'lbs', value: 'lbs' },
+							],
+						},
+						{
+							id: 'amount',
+							label: 'Amount',
+							type: 'number',
+							value: '1',
+						},
+						{
+							id: 'countries',
+							label: 'Countries',
+							type: 'array',
+							value: [ 'FR' ],
+							options: [
+								{ label: 'France', value: 'FR' },
+								{ label: 'Spain', value: 'ES' },
+							],
+						},
+					],
+				},
+			},
+		};
+
+		const { container, form, root } = renderElementInMainForm(
+			<SettingsUIPage schema={ schema } />
+		);
+		const hiddenValues = ( name: string ) =>
+			Array.from(
+				form.querySelectorAll< HTMLInputElement >(
+					`input[type="hidden"][name="${ name }"]`
+				)
+			).map( ( input ) => input.value );
+
+		try {
+			expect( hiddenValues( 'flag' ) ).toEqual( [ 'no' ] );
+			expect( hiddenValues( 'unit' ) ).toEqual( [ 'kg' ] );
+			expect( hiddenValues( 'amount' ) ).toEqual( [ '1' ] );
+			expect( hiddenValues( 'countries[]' ) ).toEqual( [ 'FR' ] );
+
+			const checkbox = container.querySelector< HTMLInputElement >(
+				'input[type="checkbox"]'
+			);
+			const selects =
+				container.querySelectorAll< HTMLSelectElement >( 'select' );
+			const number = container.querySelector< HTMLInputElement >(
+				'input[type="number"]'
+			);
+			if ( ! checkbox || selects.length !== 2 || ! number ) {
+				throw new Error( 'Expected one control per built-in type.' );
+			}
+
+			act( () => checkbox.click() );
+			act( () => changeSelect( selects[ 0 ], [ 'lbs' ] ) );
+			act( () => changeTextInput( number, '5' ) );
+			act( () => changeSelect( selects[ 1 ], [ 'FR', 'ES' ] ) );
+
+			expect( hiddenValues( 'flag' ) ).toEqual( [ 'yes' ] );
+			expect( hiddenValues( 'unit' ) ).toEqual( [ 'lbs' ] );
+			expect( hiddenValues( 'amount' ) ).toEqual( [ '5' ] );
+			expect( hiddenValues( 'countries[]' ) ).toEqual( [ 'FR', 'ES' ] );
+		} finally {
+			act( () => root.unmount() );
+			form.remove();
+		}
+	} );
+
+	it( 'fails closed when a declared component is not registered', () => {
+		jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
+		jest.spyOn( console, 'error' ).mockImplementation( () => undefined );
+
+		const schema: SettingsUISchema = {
+			id: 'test-page',
+			title: 'Test page',
+			section: 'default',
+			save: { adapter: 'form_post' },
+			groups: {
+				general: {
+					id: 'general',
+					fields: [
+						{
+							id: 'test_field',
+							label: 'Test field',
+							type: 'text',
+							component: 'test/missing-component',
+						},
+					],
+				},
+			},
+		};
+
+		const { container, root } = renderElement(
+			<SettingsUIErrorBoundary>
+				<SettingsUIPage schema={ schema } />
+			</SettingsUIErrorBoundary>
+		);
+
+		expect( container.textContent ).toContain(
+			'Something went wrong while rendering this settings page.'
+		);
+		expect( container.querySelector( 'input' ) ).toBeNull();
+		expect(
+			container.querySelector( '.woocommerce-save-button' )
+		).toBeNull();
+
+		act( () => root.unmount() );
+		container.remove();
+	} );
+
+	it( 'fails closed when no renderer resolves for a field type', () => {
+		jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
+		jest.spyOn( console, 'error' ).mockImplementation( () => undefined );
+
+		const schema: SettingsUISchema = {
+			id: 'test-page',
+			title: 'Test page',
+			section: 'default',
+			save: { adapter: 'form_post' },
+			groups: {
+				general: {
+					id: 'general',
+					fields: [
+						{
+							id: 'test_field',
+							label: 'Test field',
+							type: 'extension_defined',
+							options: [ { label: 'One', value: 'one' } ],
+						},
+					],
+				},
+			},
+		};
+
+		const { container, root } = renderElement(
+			<SettingsUIErrorBoundary>
+				<SettingsUIPage schema={ schema } />
+			</SettingsUIErrorBoundary>
+		);
+
+		// A type nothing can draw has to be as loud as a missing component,
+		// rather than dropping the field beside a live Save button.
+		expect( container.textContent ).toContain(
+			'Something went wrong while rendering this settings page.'
+		);
+		expect( container.querySelector( 'select' ) ).toBeNull();
+		expect(
+			container.querySelector( '.woocommerce-save-button' )
+		).toBeNull();
+
+		act( () => root.unmount() );
+		container.remove();
+	} );
+
 	it( 'sanitizes info fields and group descriptions before rendering', () => {
 		const schema: SettingsUISchema = {
 			id: 'test-page',
@@ -903,8 +1147,25 @@ describe( 'settings HTML rendering', () => {
 			<SettingsUIPage schema={ schema } />
 		);

-		expect( container.textContent ).toContain( 'Info field' );
-		expectUnsafeMarkupRemoved( container );
+		// DataForm paints the label for a read-only field, so the info
+		// renderer must not repeat it.
+		expect(
+			( container.textContent ?? '' ).split( 'Info field' )
+		).toHaveLength( 2 );
+
+		// The info description keeps sanitized markup while the group
+		// description renders as plain text, so the only strong tag left is
+		// the one from the info description.
+		const strongTexts = Array.from(
+			container.querySelectorAll( 'strong' )
+		).map( ( el ) => el.textContent );
+		expect( strongTexts ).toEqual( [ 'Safe' ] );
+		expect( container.querySelector( 'script' ) ).toBeNull();
+		expect( container.querySelector( 'img' ) ).toBeNull();
+		expect( container.querySelector( 'iframe' ) ).toBeNull();
+		expect( container.innerHTML ).not.toContain( 'onerror' );
+		expect( container.innerHTML ).not.toContain( 'onclick' );
+		expect( container.innerHTML ).not.toContain( 'javascript:' );

 		act( () => root.unmount() );
 		container.remove();
diff --git a/packages/js/settings-ui/src/test/registry.test.ts b/packages/js/settings-ui/src/test/registry.test.ts
index 721d76a0c25..28fc8e6b3d3 100644
--- a/packages/js/settings-ui/src/test/registry.test.ts
+++ b/packages/js/settings-ui/src/test/registry.test.ts
@@ -5,15 +5,14 @@ import {
 	__resetRegistry,
 	registerSettingsExtension,
 	resolveFieldComponent,
-	resolveFieldComponentForRendering,
 	resolveFieldVisibilityPredicate,
 	resolveGroupVisibilityPredicate,
 	resolveRegionComponent,
 	resolveSaveHandler,
 } from '../registry';
+import type { SettingsEditControl } from '../index';
 import type {
 	SettingsExtensionRegistration,
-	SettingsFieldComponent,
 	SettingsRegionComponent,
 	SettingsSaveHandler,
 	SettingsVisibilityPredicate,
@@ -26,7 +25,7 @@ describe( 'settings extension registry', () => {
 	} );

 	it( 'resolves named field components within the matching scope', () => {
-		const component: SettingsFieldComponent = () => null;
+		const component: SettingsEditControl = () => null;

 		registerSettingsExtension( {
 			scope: { page: 'registry-test', section: 'advanced' },
@@ -49,9 +48,9 @@ describe( 'settings extension registry', () => {
 	} );

 	it( 'resolves field components by documented precedence before registration recency', () => {
-		const component: SettingsFieldComponent = () => null;
-		const fieldOverride: SettingsFieldComponent = () => null;
-		const typeRenderer: SettingsFieldComponent = () => null;
+		const component: SettingsEditControl = () => null;
+		const fieldOverride: SettingsEditControl = () => null;
+		const typeRenderer: SettingsEditControl = () => null;

 		registerSettingsExtension( {
 			scope: { page: 'registry-precedence' },
@@ -92,61 +91,6 @@ describe( 'settings extension registry', () => {
 		).toBe( fieldOverride );
 	} );

-	it( 'preserves resolver fallbacks when an explicit component is missing', () => {
-		const fieldOverride: SettingsFieldComponent = () => null;
-		const typeRenderer: SettingsFieldComponent = () => null;
-
-		registerSettingsExtension( {
-			scope: { page: 'registry-missing-component' },
-			fieldOverrides: {
-				field: fieldOverride,
-			},
-			typeRenderers: {
-				text: typeRenderer,
-			},
-		} );
-
-		expect(
-			resolveFieldComponentForRendering(
-				{
-					id: 'field',
-					label: 'Field',
-					type: 'text',
-					component: 'test/missing-component',
-				},
-				{ page: 'registry-missing-component' }
-			)
-		).toBe( fieldOverride );
-
-		expect(
-			resolveFieldComponentForRendering(
-				{
-					id: 'field_without_override',
-					label: 'Field',
-					type: 'text',
-					component: 'test/missing-component',
-				},
-				{ page: 'registry-missing-component' }
-			)
-		).toBe( typeRenderer );
-	} );
-
-	it( 'fails closed when an explicit component has no registry fallback even for a native field type', () => {
-		jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
-
-		expect( () =>
-			resolveFieldComponentForRendering(
-				{
-					id: 'field',
-					label: 'Field',
-					type: 'text',
-					component: 'test/missing-component',
-				},
-				{ page: 'registry-missing-component' }
-			)
-		).toThrow( 'Component "test/missing-component" is not registered.' );
-	} );
-
 	it( 'ignores malformed registration payloads', () => {
 		const warnSpy = jest
 			.spyOn( console, 'warn' )
@@ -180,7 +124,7 @@ describe( 'settings extension registry', () => {
 	} );

 	it( 'ignores registrations outside the current page scope', () => {
-		const component: SettingsFieldComponent = () => null;
+		const component: SettingsEditControl = () => null;

 		registerSettingsExtension( {
 			scope: { page: 'registry-test-other' },
@@ -202,9 +146,9 @@ describe( 'settings extension registry', () => {
 	} );

 	it( 'distinguishes page-wide, default-section, and named-section scopes', () => {
-		const pageWideComponent: SettingsFieldComponent = () => null;
-		const defaultSectionComponent: SettingsFieldComponent = () => null;
-		const namedSectionComponent: SettingsFieldComponent = () => null;
+		const pageWideComponent: SettingsEditControl = () => null;
+		const defaultSectionComponent: SettingsEditControl = () => null;
+		const namedSectionComponent: SettingsEditControl = () => null;

 		registerSettingsExtension( {
 			scope: { page: 'registry-section-scope' },
diff --git a/packages/js/settings-ui/src/types.ts b/packages/js/settings-ui/src/types.ts
index b1db7f2ac2d..c90b3a7b88c 100644
--- a/packages/js/settings-ui/src/types.ts
+++ b/packages/js/settings-ui/src/types.ts
@@ -134,6 +134,33 @@ export type SettingsFieldComponent = (
 	props: SettingsFieldComponentProps
 ) => JSX.Element | null;

+/**
+ * The field surface a registered edit control receives. A frozen subset of
+ * the DataForm field, so extensions do not couple to package internals.
+ */
+export type SettingsEditControlField = {
+	id: string;
+	label?: string;
+	description?: string | JSX.Element;
+	placeholder?: string;
+	elements?: SettingsUIOption[];
+	getValue: ( args: { item: SettingsValues } ) => SettingsValue;
+	// Method syntax keeps this assignable from DataForm's signature, which
+	// also receives the normalized field.
+	isDisabled( args: { item: SettingsValues } ): boolean;
+};
+
+export type SettingsEditControlProps = {
+	data: SettingsValues;
+	field: SettingsEditControlField;
+	onChange: ( value: Partial< SettingsValues > ) => void;
+	hideLabelFromVision?: boolean;
+};
+
+export type SettingsEditControl = (
+	props: SettingsEditControlProps
+) => JSX.Element | null;
+
 export type SettingsVisibilityPredicateArgs = {
 	values: SettingsValues;
 	initialValues: SettingsValues;
@@ -181,9 +208,9 @@ export type SettingsExtensionScope = {

 export type SettingsExtensionRegistration = {
 	scope: SettingsExtensionScope;
-	components?: Record< string, SettingsFieldComponent >;
-	fieldOverrides?: Record< string, SettingsFieldComponent >;
-	typeRenderers?: Record< string, SettingsFieldComponent >;
+	components?: Record< string, SettingsEditControl >;
+	fieldOverrides?: Record< string, SettingsEditControl >;
+	typeRenderers?: Record< string, SettingsEditControl >;
 	fieldVisibility?: Record< string, SettingsVisibilityPredicate >;
 	groupVisibility?: Record< string, SettingsVisibilityPredicate >;
 	saveHandlers?: Record< string, SettingsSaveHandler >;
diff --git a/plugins/woocommerce/changelog/fix-wooprd-3596-settings-ui-save-button b/plugins/woocommerce/changelog/fix-wooprd-3596-settings-ui-save-button
new file mode 100644
index 00000000000..f70fe9b04d4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooprd-3596-settings-ui-save-button
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the Settings UI Save button disabled when a visibility toggle restores the original values.
diff --git a/plugins/woocommerce/client/legacy/js/admin/settings.js b/plugins/woocommerce/client/legacy/js/admin/settings.js
index 806c46083c3..b61e438be80 100644
--- a/plugins/woocommerce/client/legacy/js/admin/settings.js
+++ b/plugins/woocommerce/client/legacy/js/admin/settings.js
@@ -144,7 +144,15 @@
 			);
 		}

-		$( editPrompt );
+		// The Settings UI page owns its dirty state and unsaved-changes prompt,
+		// so the classic tracking must leave its Save button alone.
+		const isSettingsUIPage = document.body.classList.contains(
+			'woocommerce-settings-ui-page'
+		);
+
+		if ( ! isSettingsUIPage ) {
+			$( editPrompt );
+		}

 		const nodeListContainsFormElements = ( nodes ) => {
 			if ( ! nodes.length	) {
@@ -169,7 +177,9 @@
 			}
 		} );

-		observer.observe( form, { childList: true, subtree: true } );
+		if ( ! isSettingsUIPage ) {
+			observer.observe( form, { childList: true, subtree: true } );
+		}

 		// Sorting
 		$( 'table.wc_gateways tbody, table.wc_shipping tbody' ).sortable( {
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/settings-ui-component-registration/settings-ui-component-registration.php b/plugins/woocommerce/tests/e2e/test-plugins/settings-ui-component-registration/settings-ui-component-registration.php
index 584bd39b428..0b51299f659 100644
--- a/plugins/woocommerce/tests/e2e/test-plugins/settings-ui-component-registration/settings-ui-component-registration.php
+++ b/plugins/woocommerce/tests/e2e/test-plugins/settings-ui-component-registration/settings-ui-component-registration.php
@@ -51,8 +51,12 @@ window.wcSettingsUI.registerSettingsExtension( {
 				'Registered settings UI component',
 				window.wp.element.createElement( 'input', {
 					'aria-label': 'Registered component value',
-					onChange: function ( event ) { props.onChange( event.target.value ); },
-					value: typeof props.value === 'string' ? props.value : '',
+					onChange: function ( event ) {
+						var change = {};
+						change[ props.field.id ] = event.target.value;
+						props.onChange( change );
+					},
+					value: String( props.field.getValue( { item: props.data } ) ?? '' ),
 				} )
 			);
 		},
diff --git a/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts b/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
index 87d3d4da1e2..f079de3f130 100644
--- a/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
@@ -82,22 +82,13 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
 			'gap',
 			'24px'
 		);
-		const sectionCard = settingsUI
-			.locator( '.wc-settings-ui__section-card' )
+		const dataForm = settingsUI
+			.locator( '.dataforms-layouts__wrapper' )
 			.first();
-		await expect( sectionCard ).toHaveCSS( 'display', 'flex' );
-		await expect( sectionCard ).toHaveCSS( 'border-top-width', '1px' );
-		await expect( sectionCard ).toHaveCSS( 'border-radius', '8px' );
-		await expect( sectionCard ).toHaveCSS(
-			'background-color',
-			'rgb(255, 255, 255)'
-		);
-		await expect(
-			sectionCard.locator( '.wc-settings-ui__section-header' )
-		).toHaveCSS( 'padding', '24px' );
+		await expect( dataForm ).toBeVisible();
 		await expect(
-			sectionCard.locator( '.wc-settings-ui__section-fields' )
-		).toHaveCSS( 'padding', '0px 24px 24px' );
+			settingsUI.locator( '.wc-settings-ui__section-card' )
+		).toHaveCount( 0 );

 		const weightUnit = settingsUI.getByLabel( 'Weight unit' );
 		expect( [ 'kg', 'g', 'lbs', 'oz' ] ).toContain(
@@ -122,6 +113,74 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
 		expect( compatibilityFailures ).toEqual( [] );
 	} );

+	test( 'saves a DataForm edit through the form post round-trip', async ( {
+		page,
+	} ) => {
+		await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
+		const settingsUI = page.locator( '[data-wc-settings-ui]' );
+		await expect( settingsUI ).toBeVisible();
+
+		const weightUnit = settingsUI.getByLabel( 'Weight unit' );
+		const originalUnit = await weightUnit.inputValue();
+		const updatedUnit = originalUnit === 'kg' ? 'g' : 'kg';
+		const saveButton = settingsUI.getByRole( 'button', { name: 'Save' } );
+
+		try {
+			await expect( saveButton ).toBeDisabled();
+			await weightUnit.selectOption( updatedUnit );
+			await expect( saveButton ).toBeEnabled();
+			await saveButton.click();
+
+			await expect(
+				page.getByText( 'Your settings have been saved.' )
+			).toBeVisible();
+			await page.reload();
+			await expect( settingsUI.getByLabel( 'Weight unit' ) ).toHaveValue(
+				updatedUnit
+			);
+		} finally {
+			// The save persists a site-wide option, so restore it even when an
+			// assertion fails. Plugins and themes are skipped because booting
+			// the extensions earlier specs install can exhaust the CLI
+			// container's memory before the command runs.
+			await wpCLI(
+				`wp option update woocommerce_weight_unit ${ originalUnit } --skip-plugins --skip-themes`
+			);
+		}
+	} );
+
+	test( 'toggles dependent fields with a visibility rule', async ( {
+		page,
+	} ) => {
+		await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
+		const settingsUI = page.locator( '[data-wc-settings-ui]' );
+		await expect( settingsUI ).toBeVisible();
+
+		const enableReviews = settingsUI.getByLabel( 'Enable product reviews' );
+		const verifiedOwnerLabel = settingsUI.getByLabel(
+			'Show "verified owner" label on customer reviews'
+		);
+		const verifiedOwnersOnly = settingsUI.getByLabel(
+			'Reviews can only be left by "verified owners"'
+		);
+		const saveButton = settingsUI.getByRole( 'button', { name: 'Save' } );
+
+		await expect( enableReviews ).toBeChecked();
+		await expect( verifiedOwnerLabel ).toBeVisible();
+		await expect( verifiedOwnersOnly ).toBeVisible();
+
+		await enableReviews.uncheck();
+		await expect( verifiedOwnerLabel ).toHaveCount( 0 );
+		await expect( verifiedOwnersOnly ).toHaveCount( 0 );
+		await expect( saveButton ).toBeEnabled();
+
+		await enableReviews.check();
+		await expect( verifiedOwnerLabel ).toBeVisible();
+		await expect( verifiedOwnersOnly ).toBeVisible();
+		// Restoring the original value leaves nothing to save.
+		await expect( saveButton ).toBeDisabled();
+	} );
+
 	test( 'loads a declared component registration before mounting settings', async ( {
 		page,
 	} ) => {
@@ -135,6 +194,15 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
 		await expect(
 			page.getByTestId( 'settings-ui-registered-component' )
 		).toContainText( 'Registered settings UI component' );
+
+		const registeredInput = page.getByLabel( 'Registered component value' );
+		await expect( registeredInput ).toHaveValue( 'Initial value' );
+		await registeredInput.fill( 'Updated value' );
+		await expect(
+			page.locator(
+				'input[name="settings_ui_component_registered_value"]'
+			)
+		).toHaveValue( 'Updated value' );
 	} );

 	test( 'fails closed when an executed script omits its component registration', async ( {