Commit 13ba1ded1f0 for woocommerce
commit 13ba1ded1f0cd61bd42bf46985614660b8b1b1d2
Author: Ahmed <ahmed.el.azzabi@automattic.com>
Date: Thu Aug 20 12:34:29 2026 +0100
Validate Settings UI schemas before rendering (#67334)
* feat(settings): validate Settings UI schemas
Settings UI schemas previously reached the client after compatibility normalization without a complete server-side contract. Unsupported types and malformed renderer metadata could therefore cross the PHP-to-JavaScript boundary.\n\nAdd an explicit validator for the current renderer vocabulary and schema shapes, keep legacy info fields non-saving, and cover native and legacy provider paths. This is additive and does not change public signatures or the classic save pipeline.\n\nRefs WOOPRD-3593
* fix(settings): guard Settings UI rendering failures
Settings UI requests previously cached schemas without validating the renderer contract, filtered invalid script handles silently, and exposed only a generic schema failure. Those paths could mount an unsafe page or hide the classic Save button instead of failing safely.\n\nValidate before cache assignment, preserve precise request-scoped diagnostics, require registered and enqueued extension scripts, and support an explicit non-persistent classic rendering request. Legacy call sites retain guarded fallback behavior during mixed-version updates.\n\nRefs WOOPRD-3593
* fix(settings-ui): fail closed for missing components
Previously, an explicitly named component that was absent from the browser registry could fall through to an editable native field. That made a partially registered extension appear usable and exposed a Save action for the wrong control.
Treat explicit component resolution as mandatory, contain the failure at the page boundary, and provide a request-only classic settings link. Add unit, E2E fixture, and documentation coverage for the server and runtime failure paths.
Refs WOOPRD-3593
* chore(settings-ui): add fallback changelogs
Record the fail-closed component resolution fix for both the settings UI package and the WooCommerce plugin release notes.
Refs WOOPRD-3593
* test(settings-ui): simplify fallback coverage
Remove redundant execution markers and serial configuration from the component fallback tests. Centralize mock and URL cleanup so failed assertions cannot leak test state.
Refs WOOPRD-3593
* fix(settings): reject duplicate legacy schema groups
Legacy settings groups were keyed by ID while the React schema was built, so duplicate IDs silently overwrote earlier groups and could produce an ambiguous schema. Reject duplicates, including the string ID "0", so invalid schemas fail closed and the classic settings screen remains available.
Refs WOOPRD-3593
* refactor(settings): Simplify Settings UI fallback coverage
The fallback path enqueued extension handles twice and the test suite repeated feature-flag and duplicate-schema behavior across layers.\n\nKeep script ownership in the request context, remove redundant integration and browser coverage, and make the remaining component-registration fixture exercise the real dependency graph.\n\nThis reduces maintenance cost without changing the public API or classic fallback guarantees.
* fix(e2e): Mount Settings UI fixture in wp-env
CI tests WooCommerce from a packaged artifact that excludes the tests directory, leaving the runtime fixture symlink dangling and preventing WP-CLI from finding the plugin.
Mount the fixture directly through wp-env mappings so the same test setup works with both source checkouts and CI artifacts. This also removes the per-suite symlink lifecycle.
* fix(settings): preserve Settings UI fallback compatibility
Settings UI fallback validation could reject scripts registered later in the admin enqueue lifecycle, alter the public component resolver contract, and leave classic forms without a Save button after a mount failure.
Separate handle declaration collection from registration and enqueue checks, keep strict component resolution inside the renderer, restore badge intent fallback behavior, and preserve classic renderer state while reporting caught failures.
Refs WOOPRD-3593
* chore(settings): update Settings UI fallback changelogs
Describe the schema, script handle, and renderer compatibility guarantees covered by the fix.
Refs WOOPRD-3593
* fix(settings): preserve Settings UI extension compatibility
The merged Settings UI prerequisite checked script registrations while it collected dependencies, before later extension hooks could register their declared handles. Schema validation also rejected the public custom type renderer contract.\n\nSeparate declaration failures from registration checks, accept extension-defined types that use valid Settings UI values, and keep the fallback notice on public WordPress components. Add focused regression coverage and restore the extension documentation.\n\nRefs WOOPRD-3593
* fix(settings): preserve Settings UI adapter contracts
The schema boundary could reject legacy controls whose options are created at render time, while the browser resolver could also bypass registered extension fallbacks or render an unknown type as text.
Generate canonical page and country options during legacy conversion, preserve the documented renderer order, and fail closed at the page boundary only when no registered or native renderer exists. Focus the recovery notice and add real-schema regression coverage.
This keeps extension-defined type renderers and direct NativeSettingsField callers compatible while retaining strict schema validation.
Refs WOOPRD-3593
* fix(settings): preserve Settings UI extension contracts
Choice fields with empty option sets are valid in classic settings and in the client renderer. Rejecting them in PHP forced third-party integrations back to the classic page.
Accept missing and empty choice lists while keeping malformed option data fail-closed. Restore the established script-handle resolution check and use a new loading check for WooCommerce registration and enqueue validation.
This keeps third-party Settings UI adapters compatible without weakening structural schema validation.
Refs WOOPRD-3593
* test(settings): validate every Products settings section
The real-schema regression test covered only the default Products section. This left Inventory, Downloadable products, and the registered Advanced section able to fall back without failing this test. Validate every current Products section and assert that the coverage list stays aligned when core adds another section.
* docs(settings): clarify script handle requirements
The guide described invalid declared handles only in general terms. Extensions could not tell that every handle must be a non-empty string and available before rendering. Document the enforced contract and the classic fallback consequence.\n\nRefs WOOPRD-3593
* docs(settings): fix guarded method reference
The guarded-call registry named a method that does not exist. This could direct future changes to check the wrong compatibility boundary. Reference the actual script-loading failure method.\n\nRefs WOOPRD-3593
* test(settings): remove redundant feature flag setup
The suite already enables the Settings UI feature before all tests. Enabling it again inside one test added an unrelated dependency on the base URL. Use the shared setup as the single source of truth.\n\nRefs WOOPRD-3593
* fix(settings-ui): use the Notice action API
The error boundary rendered its recovery link inside an unsupported Notice child wrapper. That mixed the action with assertive notice content and missed the component's action styling. Pass the classic-settings link through the supported actions property and cover its rendered structure.\n\nRefs WOOPRD-3593
* fix(settings): preserve temporal range attributes
The new schema validator accepted range attributes only for numeric fields. Valid date, time, and local datetime controls therefore fell back to classic settings even though the native renderer supports their HTML attributes. Preserve those attributes at the PHP boundary while leaving DataForm rule semantics to WOOPRD-3598.\n\nRefs WOOPRD-3593
* fix(settings): preflight the Settings UI page id
The admin body class was printed before extension-owned page ID resolution ran. If that callback threw, classic settings rendered under Settings UI CSS and the Save button stayed hidden. Cache the page ID or its original failure before adding body classes, then reuse the result during output and diagnostics.\n\nRefs WOOPRD-3593
* fix(settings): avoid generated group ID collisions
Separate runs of loose legacy fields reused the generated default group ID. Strict duplicate validation then forced the whole page to classic settings, while explicit IDs could also collide with generated names. Reserve extension-declared IDs and allocate stable suffixes only for generated groups, while continuing to reject duplicate declared IDs.\n\nRefs WOOPRD-3593
* refactor(settings): keep schema validation structural
The PHP validator previously copied the renderer field vocabulary and HTML range-attribute rules. Those copies could drift from React, the browser, and classic settings, which caused compatibility regressions for third-party fields.
Validate only the Settings UI transport contract and scalar custom attributes. Leave field rendering semantics to registered components and the browser, while the existing WC_Admin_Settings flow remains responsible for save validation and sanitization.
This removes duplicated rules and preserves structural fallback for values that cannot safely cross the PHP-to-JavaScript boundary.
Refs WOOPRD-3593
* fix(settings): preserve extension declaration contracts
Whitespace-padded script handles passed the initial shape check but failed later registration checks with an unclear error. The component fallback and duplicate group behavior also needed explicit extension-facing documentation.
Normalize handles before deduplication and loading, add regression coverage, and document the fail-closed component and duplicate group contracts. This keeps strict fallback behavior while accepting otherwise valid third-party handle declarations.
Refs WOOPRD-3593
* refactor(settings): simplify schema validation coverage
The Settings UI boundary had repeated fixtures and overlapping unit and end-to-end cases. This made the change harder to review without adding distinct compatibility coverage.
Reuse the shared test setup, keep each behavior at its strongest test layer, and use keyed lookups for duplicate identifiers. Validation, classic fallback, and third-party extension behavior stay unchanged.
Refs WOOPRD-3593
* fix(settings): Harden Settings UI compatibility fallback
Schema error logging could rethrow a cached page identifier failure, numeric legacy group identifiers could force classic fallback, and country option generation assumed that WooCommerce was fully initialized. Test rendering could also leave an output buffer open after an exception.
Use the stable classic page identifier for failure logging, preserve numeric group keys as JSON objects at the transport boundary, guard the countries controller, and close test-owned buffers reliably.
This keeps third-party legacy definitions compatible while preserving the fail-closed behavior for malformed schemas.
Refs WOOPRD-3593
* fix(settings): contain Settings UI resolution failures
Settings UI resolution errors could escape while shared settings data was built. These errors could stop the request after the classic fallback had rendered.
Contain adapter resolution failures at the shared-settings boundary, log fallback-causing failures at error level, and document the page ID preflight that keeps the classic Save button visible.
Third-party adapter failures now preserve the original shared settings and remain visible in WooCommerce logs.
Refs WOOPRD-3593
* fix(settings): match classic country option order
The classic multi-country field sorts declared country options by label. The Settings UI adapter kept the declaration order, which created a visible parity difference for extensions.
Sort only multi_select_countries options before the adapter normalizes them. Other field types keep their existing order.
Refs WOOPRD-3593
* fix(settings): mark schema resolved on script-handle failure
When get_schema() returned early because has_script_handles_failed()
was true, it left schema_resolved false. A later has_schema_failed()
call then entered resolve_schema() and invoked the extension schema
adapter anyway, defeating the fail-closed path and repeating
third-party work or logging that the script-handle failure had
already made moot.
Set schema_resolved before returning null in that branch, and add a
regression test that asserts the schema is not resolved after a
script-handle failure.
Refs WOOPRD-3593
* test(e2e): assert full URL contract on classic settings fallback
The classic-fallback link test only checked for wc_settings_ui=classic
in the URL after clicking, even though the documented contract is
that the link preserves the current tab, section, and existing query
parameters. Assert the full expectation so a regression that drops
the section or other parameters fails the test.
Refs WOOPRD-3593
* fix(settings): route has_schema_failed() through get_schema() to stay fail-closed
<commit_analysis>
- Previous behavior: has_schema_failed() and get_schema_failure_reason() called
resolve_schema() directly whenever schema_resolved was still false.
- Problem: resolve_schema() does not check has_script_handles_failed(). If a
caller invoked has_schema_failed() (or get_schema_failure_reason()) before
ever calling get_schema(), the fail-closed script-handle check was bypassed
and the extension's schema adapter ran anyway - repeating third-party work
and logging that the fail-closed path exists to avoid, even though the
caller only asked about failure state.
- Solution: have both methods resolve through get_schema() instead of calling
resolve_schema() directly, so the has_script_handles_failed() short-circuit
in get_schema() always runs first regardless of call order. Added a
regression test asserting the schema resolution count stays zero when
has_schema_failed() is called before get_schema() after a script-handle
failure.
- Trade-offs: none - schema_failed still correctly stays false on a script
handle failure (existing contract), this only closes the call-order gap.
</commit_analysis>
Refs WOOPRD-3593
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 907225ff651..aa09ba12620 100644
--- a/docs/extensions/settings-and-config/registering-settings-ui-components.md
+++ b/docs/extensions/settings-and-config/registering-settings-ui-components.md
@@ -156,12 +156,17 @@ registerSettingsExtension( {
} );
```
+The PHP schema validator accepts extension-defined field types when their values use the Settings UI value contract. The extension script must register the matching renderer before the page mounts.
+
Resolution order is:
1. `field.component`
2. `fieldOverrides[ field.id ]`
3. `typeRenderers[ field.type ]`
-4. Native field renderer
+
+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.
+
+For a field without `field.component`, the native field renderer is the final fallback after field overrides and type renderers.
## Enqueue the component script
@@ -196,3 +201,9 @@ final class My_Plugin_Settings_UI_Page extends LegacySettingsPageAdapter {
```
WooCommerce loads the settings UI package first, then your script, then mounts the settings app.
+
+## Failure and fallback behavior
+
+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.
diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index f3db6227396..04b829747b5 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -200,6 +200,8 @@ The legacy adapter converts the existing `get_settings()` array into a canonical
Fields before the first `title` marker are placed into a default group automatically.
+For legacy country and page selectors, the adapter creates the same option list that the classic renderer creates at render time. Other select, radio, and multiselect fields can omit `options` or use an empty array. The Settings UI then renders the same empty choice set that the classic settings API accepts. When a field supplies options, WooCommerce validates their structure before rendering.
+
The default save adapter is `form_post`, which serializes hidden inputs so `WC_Admin_Settings::save_fields()` continues to save the submitted values.
## Custom component migration
@@ -256,6 +258,14 @@ final class My_Plugin_Settings_UI_Page extends LegacySettingsPageAdapter {
The settings embed script depends on the settings UI package and these handles only for the opted-in page. Other settings pages do not load it.
+WooCommerce validates the schema structure and declared script handles on the server. Structural checks cover identifiers, references, list and map shapes, values that cross the PHP-to-JavaScript boundary, and renderer metadata. PHP does not keep a list of supported field types or decide whether an HTML attribute such as `min`, `max`, or `step` applies to a field. The registered component and the browser own those rendering rules. The existing `WC_Admin_Settings` flow remains responsible for validating, sanitizing, and saving submitted values.
+
+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 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.
+
## Save adapters
The settings UI supports two save adapters:
@@ -351,5 +361,5 @@ 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 is unsupported.
+- A field type has no registered or native renderer.
- A field declares an unknown save adapter.
diff --git a/packages/js/settings-ui/changelog/fix-wooprd-3593-missing-components b/packages/js/settings-ui/changelog/fix-wooprd-3593-missing-components
new file mode 100644
index 00000000000..12a3ca1acc7
--- /dev/null
+++ b/packages/js/settings-ui/changelog/fix-wooprd-3593-missing-components
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fail closed while rendering a missing declared settings component without changing the public component resolver contract.
diff --git a/packages/js/settings-ui/src/native-fields.tsx b/packages/js/settings-ui/src/native-fields.tsx
index e317b69b1e9..945a7c9688e 100644
--- a/packages/js/settings-ui/src/native-fields.tsx
+++ b/packages/js/settings-ui/src/native-fields.tsx
@@ -40,12 +40,25 @@ const textInputTypes: TextInputType[] = [
'tel',
];
+const nativeFieldTypes = new Set( [
+ 'info',
+ 'checkbox',
+ 'textarea',
+ 'select',
+ 'radio',
+ 'array',
+ 'number',
+] );
+
const toStringValue = ( value: SettingsValue ) =>
value === null || typeof value === 'undefined' ? '' : String( value );
const isTextInputType = ( type: string ): type is TextInputType =>
textInputTypes.includes( type as TextInputType );
+export const isNativeSettingsFieldType = ( type: string ) =>
+ nativeFieldTypes.has( type ) || isTextInputType( type );
+
// Use HTML boolean attribute presence semantics: disabled="false" still
// means disabled, while a boolean false remains false.
const toPresenceBooleanCustomAttribute = (
diff --git a/packages/js/settings-ui/src/registry.ts b/packages/js/settings-ui/src/registry.ts
index b36c9627818..58be9697b64 100644
--- a/packages/js/settings-ui/src/registry.ts
+++ b/packages/js/settings-ui/src/registry.ts
@@ -215,6 +215,25 @@ 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 810c89c1f0e..3a2023d1669 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -22,9 +22,12 @@ import type { ErrorInfo, ReactNode } from 'react';
import { HiddenInputs } from './hidden-inputs';
import { error, warn } from './diagnostics';
import { sanitizeSettingsHtml } from './html';
-import { NativeSettingsField } from './native-fields';
import {
- resolveFieldComponent,
+ isNativeSettingsFieldType,
+ NativeSettingsField,
+} from './native-fields';
+import {
+ resolveFieldComponentForRendering,
resolveFieldVisibilityPredicate,
resolveGroupVisibilityPredicate,
resolveRegionComponent,
@@ -332,6 +335,12 @@ const getVisible = ( {
const getAllFields = ( schema: SettingsUISchema ): SettingsUIField[] =>
Object.values( schema.groups ).flatMap( ( group ) => group.fields );
+const getClassicSettingsUrl = () => {
+ const url = new URL( window.location.href );
+ url.searchParams.set( 'wc_settings_ui', 'classic' );
+ return url.toString();
+};
+
type ErrorBoundaryProps = {
children: ReactNode;
};
@@ -345,6 +354,7 @@ export class SettingsUIErrorBoundary extends Component<
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false };
+ private errorRegion: HTMLDivElement | null = null;
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
@@ -355,17 +365,43 @@ export class SettingsUIErrorBoundary extends Component<
error: caughtError,
errorInfo,
} );
+ this.errorRegion?.focus();
}
render() {
if ( this.state.hasError ) {
+ const message = __(
+ 'Something went wrong while rendering this settings page.',
+ 'woocommerce'
+ );
+
return (
- <Notice status="error" isDismissible={ false }>
- { __(
- 'Something went wrong while rendering this settings page. Reload the page with the settings UI feature disabled to use the classic settings screen.',
- 'woocommerce'
- ) }
- </Notice>
+ <div
+ className="wc-settings-ui__error"
+ role="region"
+ aria-label={ message }
+ tabIndex={ -1 }
+ ref={ ( region ) => {
+ this.errorRegion = region;
+ } }
+ >
+ <Notice
+ status="error"
+ isDismissible={ false }
+ actions={ [
+ {
+ label: __(
+ 'Use classic settings',
+ 'woocommerce'
+ ),
+ url: getClassicSettingsUrl(),
+ variant: 'link',
+ },
+ ] }
+ >
+ { message }
+ </Notice>
+ </div>
);
}
@@ -879,11 +915,26 @@ export const SettingsUIPage = ( {
<GroupHeader group={ group } />
<div className="wc-settings-ui__section-fields">
{ group.fields.map( ( field ) => {
- const FieldComponent =
- resolveFieldComponent(
+ const RegisteredFieldComponent =
+ resolveFieldComponentForRendering(
field,
context
- ) || NativeSettingsField;
+ );
+
+ 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 (
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 ef96771b867..9bf169ef89f 100644
--- a/packages/js/settings-ui/src/test/html-rendering.test.tsx
+++ b/packages/js/settings-ui/src/test/html-rendering.test.tsx
@@ -19,9 +19,9 @@ jest.mock( '@wordpress/admin-ui', () => ( {
/**
* Internal dependencies
*/
-import { SettingsUIPage } from '../settings-ui-page';
+import { SettingsUIErrorBoundary, SettingsUIPage } from '../settings-ui-page';
import { __resetRegistry, registerSettingsExtension } from '../registry';
-import type { SettingsUISchema } from '../types';
+import type { SettingsUIField, SettingsUISchema } from '../types';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -40,6 +40,23 @@ const renderElement = ( element: JSX.Element ) => {
return { container, root };
};
+const createSingleFieldSchema = (
+ field: SettingsUIField,
+ overrides: Partial< SettingsUISchema > = {}
+): SettingsUISchema => ( {
+ id: 'test-page',
+ title: 'Test page',
+ section: 'default',
+ save: { adapter: 'none' },
+ ...overrides,
+ groups: {
+ general: {
+ id: 'general',
+ fields: [ field ],
+ },
+ },
+} );
+
const renderElementInMainForm = ( element: JSX.Element ) => {
const form = document.createElement( 'form' );
form.id = 'mainform';
@@ -99,8 +116,12 @@ const expectUnsafeMarkupRemoved = ( container: HTMLElement ) => {
};
describe( 'settings HTML rendering', () => {
+ const originalUrl = window.location.href;
+
afterEach( () => {
__resetRegistry();
+ jest.restoreAllMocks();
+ window.history.replaceState( {}, '', originalUrl );
} );
it( 'renders settings as centered sections and cards', () => {
@@ -199,6 +220,149 @@ describe( 'settings HTML rendering', () => {
container.remove();
} );
+ it( 'fails closed when an explicit component is not registered', () => {
+ window.history.replaceState(
+ {},
+ '',
+ '/wp-admin/admin.php?page=wc-settings&tab=products§ion=advanced&preserved=yes#wc-settings'
+ );
+ jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
+ jest.spyOn( console, 'error' ).mockImplementation( () => undefined );
+ const schema = createSingleFieldSchema(
+ {
+ id: 'test_field',
+ label: 'Test field',
+ type: 'text',
+ component: 'test/missing-component',
+ },
+ {
+ id: 'products',
+ title: 'Products',
+ section: 'advanced',
+ save: { adapter: 'form_post' },
+ }
+ );
+
+ 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();
+ const classicAction = Array.from(
+ container.querySelectorAll( 'a' )
+ ).find(
+ ( link ) => link.textContent?.trim() === 'Use classic settings'
+ );
+ expect( classicAction ).toBeDefined();
+ expect(
+ classicAction?.closest( '.components-notice__actions' )
+ ).not.toBeNull();
+ expect(
+ container.querySelector( '.components-notice__content' )?.firstChild
+ ?.textContent
+ ).toBe( 'Something went wrong while rendering this settings page.' );
+ const classicUrl = new URL( classicAction?.href || '' );
+ expect( classicUrl.searchParams.getAll( 'wc_settings_ui' ) ).toEqual( [
+ 'classic',
+ ] );
+ expect( classicUrl.searchParams.get( 'page' ) ).toBe( 'wc-settings' );
+ expect( classicUrl.searchParams.get( 'tab' ) ).toBe( 'products' );
+ expect( classicUrl.searchParams.get( 'section' ) ).toBe( 'advanced' );
+ expect( classicUrl.searchParams.get( 'preserved' ) ).toBe( 'yes' );
+ expect( classicUrl.hash ).toBe( '#wc-settings' );
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+
+ it( 'uses a field override when an explicit component is not registered', () => {
+ const FieldOverride = () => <div>Extension field override</div>;
+ registerSettingsExtension( {
+ scope: { page: 'test-page' },
+ fieldOverrides: { test_field: FieldOverride },
+ } );
+ const schema = createSingleFieldSchema( {
+ 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( 'Extension field override' );
+ expect( container.textContent ).not.toContain(
+ 'Something went wrong while rendering this settings page.'
+ );
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+
+ it( 'renders extension-defined types through registered type renderers', () => {
+ const TypeRenderer = () => <div>Extension type renderer</div>;
+ registerSettingsExtension( {
+ scope: { page: 'test-page' },
+ typeRenderers: { extension_defined: TypeRenderer },
+ } );
+ const schema = createSingleFieldSchema( {
+ id: 'test_field',
+ label: 'Test field',
+ type: 'extension_defined',
+ } );
+
+ const { container, root } = renderElement(
+ <SettingsUIErrorBoundary>
+ <SettingsUIPage schema={ schema } />
+ </SettingsUIErrorBoundary>
+ );
+
+ expect( container.textContent ).toContain( 'Extension type renderer' );
+ expect( container.querySelector( 'input' ) ).toBeNull();
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+
+ it( 'fails closed and focuses the error region for an unrenderable type', () => {
+ jest.spyOn( console, 'error' ).mockImplementation( () => undefined );
+ const schema = createSingleFieldSchema( {
+ id: 'test_field',
+ label: 'Test field',
+ type: 'extension_defined',
+ } );
+
+ const { container, root } = renderElement(
+ <SettingsUIErrorBoundary>
+ <SettingsUIPage schema={ schema } />
+ </SettingsUIErrorBoundary>
+ );
+
+ const errorRegion = container.querySelector( '.wc-settings-ui__error' );
+ expect( errorRegion ).toHaveAttribute( 'role', 'region' );
+ expect( errorRegion ).toHaveAttribute( 'tabindex', '-1' );
+ expect( errorRegion?.ownerDocument.activeElement ).toBe( errorRegion );
+ expect( container.querySelector( 'input' ) ).toBeNull();
+ expect(
+ container.querySelector( '.woocommerce-save-button' )
+ ).toBeNull();
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+
it( 'sanitizes native field descriptions before rendering', () => {
const schema: SettingsUISchema = {
id: 'test-page',
diff --git a/packages/js/settings-ui/src/test/native-fields.test.tsx b/packages/js/settings-ui/src/test/native-fields.test.tsx
index 98ffb26a007..6519c02becb 100644
--- a/packages/js/settings-ui/src/test/native-fields.test.tsx
+++ b/packages/js/settings-ui/src/test/native-fields.test.tsx
@@ -10,7 +10,10 @@ import { createRoot } from 'react-dom/client';
/**
* Internal dependencies
*/
-import { NativeSettingsField } from '../native-fields';
+import {
+ isNativeSettingsFieldType,
+ NativeSettingsField,
+} from '../native-fields';
import type {
SettingsFieldComponentProps,
SettingsUIField,
@@ -545,4 +548,12 @@ describe( 'NativeSettingsField', () => {
).toBeNull();
} );
} );
+
+ it( 'reports which field types have a native renderer', () => {
+ expect( isNativeSettingsFieldType( 'text' ) ).toBe( true );
+ expect( isNativeSettingsFieldType( 'select' ) ).toBe( true );
+ expect( isNativeSettingsFieldType( 'extension_defined' ) ).toBe(
+ false
+ );
+ } );
} );
diff --git a/packages/js/settings-ui/src/test/registry.test.ts b/packages/js/settings-ui/src/test/registry.test.ts
index 46a8d6a84a7..721d76a0c25 100644
--- a/packages/js/settings-ui/src/test/registry.test.ts
+++ b/packages/js/settings-ui/src/test/registry.test.ts
@@ -5,6 +5,7 @@ import {
__resetRegistry,
registerSettingsExtension,
resolveFieldComponent,
+ resolveFieldComponentForRendering,
resolveFieldVisibilityPredicate,
resolveGroupVisibilityPredicate,
resolveRegionComponent,
@@ -21,6 +22,7 @@ import type {
describe( 'settings extension registry', () => {
afterEach( () => {
__resetRegistry();
+ jest.restoreAllMocks();
} );
it( 'resolves named field components within the matching scope', () => {
@@ -90,6 +92,61 @@ 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' )
@@ -210,9 +267,9 @@ describe( 'settings extension registry', () => {
{ page: 'registry-section-scope', section: 'advanced' }
)
).toBeUndefined();
- const warnSpy = jest
+ const missingComponentWarnSpy = jest
.spyOn( console, 'warn' )
- .mockImplementation( jest.fn() );
+ .mockImplementation( () => undefined );
expect(
resolveFieldComponent(
{
@@ -224,7 +281,13 @@ describe( 'settings extension registry', () => {
{ page: 'registry-section-scope', section: '' }
)
).toBeUndefined();
- warnSpy.mockRestore();
+ expect( missingComponentWarnSpy ).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Component "named-section" is not registered.'
+ ),
+ expect.any( Object )
+ );
+ missingComponentWarnSpy.mockRestore();
expect(
resolveFieldComponent(
{
diff --git a/plugins/woocommerce/.wp-env.e2e.json b/plugins/woocommerce/.wp-env.e2e.json
index 50937b52be8..a2051ed3855 100644
--- a/plugins/woocommerce/.wp-env.e2e.json
+++ b/plugins/woocommerce/.wp-env.e2e.json
@@ -33,6 +33,7 @@
},
"mappings": {
"wp-content/plugins/e2e-test-bin": "./tests/e2e/bin",
+ "wp-content/plugins/settings-ui-component-registration": "./tests/e2e/test-plugins/settings-ui-component-registration",
"test-data/images/": "./tests/e2e/test-data/images/",
"wp-content/themes/emptytheme": "./tests/e2e/themes/blocks/emptytheme",
"wp-content/themes/theme-with-woo-templates": "./tests/e2e/themes/blocks/theme-with-woo-templates",
diff --git a/plugins/woocommerce/changelog/fix-wooprd-3593-settings-ui-components b/plugins/woocommerce/changelog/fix-wooprd-3593-settings-ui-components
new file mode 100644
index 00000000000..40761354a07
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooprd-3593-settings-ui-components
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Validate Settings UI schemas and script handles before mounting, with a complete classic fallback for server-side failures.
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
index 5e5ad88b8e6..27d371995fc 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
@@ -146,9 +146,12 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
return $classes;
}
+ // Preflight and cache adapter failures before this body class can hide the classic Save button.
+ $context->get_page_id();
+
// The legacy fallback renderer needs the classic styling: the settings UI
// body class hides the legacy Save button via CSS.
- if ( $context->has_schema_failed() || $context->has_script_handles_failed() ) {
+ if ( $context->has_schema_failed() || $context->has_script_handle_loading_failed() ) {
return $classes;
}
@@ -179,17 +182,17 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
*
* @since 10.9.0
*
- * @param SettingsUIPageInterface $settings_ui_page Settings UI page adapter.
- * @param string $section_id Section id.
- * @param string $reason Fallback reason.
+ * @param string $page_id Settings UI page id.
+ * @param string $section_id Section id.
+ * @param string $reason Fallback reason.
*/
- private function log_settings_ui_fallback( SettingsUIPageInterface $settings_ui_page, string $section_id, string $reason ): void {
+ private function log_settings_ui_fallback( string $page_id, string $section_id, string $reason ): void {
wc_doing_it_wrong(
'WC_Settings_Page::output',
sprintf(
/* translators: 1: settings page id, 2: settings section id, 3: fallback reason. */
__( 'Settings UI rendering for page "%1$s" section "%2$s" fell back to the legacy settings renderer. Reason: %3$s', 'woocommerce' ),
- $settings_ui_page->get_page_id(),
+ $page_id,
'' === $section_id ? 'default' : $section_id,
$reason
),
@@ -410,40 +413,67 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
public function output() {
global $current_section;
- $section = is_string( $current_section ) ? $current_section : '';
- $context = $this->get_settings_ui_request_context( $section );
+ $section = is_string( $current_section ) ? $current_section : '';
+ $context = $this->get_settings_ui_request_context( $section );
+ $hide_save_button_overridden = false;
+ $hide_save_button_existed = array_key_exists( 'hide_save_button', $GLOBALS );
+ $previous_hide_save_button = $hide_save_button_existed ? $GLOBALS['hide_save_button'] : null;
- if ( $context && $context->is_rendering_enabled() ) {
- $settings_ui_page = $context->get_settings_ui_page();
- assert( $settings_ui_page instanceof SettingsUIPageInterface );
-
- if ( $context->has_schema_failed() ) {
- $this->log_settings_ui_fallback(
- $settings_ui_page,
- $section,
- __( 'Settings UI schema generation failed.', 'woocommerce' )
- );
- } else {
- $script_handles = $context->get_script_handles();
-
- if ( $context->has_script_handles_failed() ) {
- $this->log_settings_ui_fallback( $settings_ui_page, $section, $context->get_script_handles_failure_reason() );
- } else {
- foreach ( $script_handles as $script_handle ) {
- wp_enqueue_script( $script_handle );
- }
+ try {
+ if ( $context && $context->is_rendering_enabled() ) {
+ $page_id = $context->get_page_id();
- $GLOBALS['hide_save_button'] = true;
+ if ( $context->has_schema_failed() ) {
+ $schema_failure_reason = method_exists( $context, 'get_schema_failure_reason' )
+ ? $context->get_schema_failure_reason()
+ : __( 'Settings UI schema generation failed.', 'woocommerce' );
- printf(
- '<div id="%1$s" data-wc-settings-ui="1" data-wc-settings-page="%2$s" data-wc-settings-section="%3$s"></div>',
- esc_attr( 'wc_settings_ui_' . sanitize_html_class( $this->id ) . '_' . sanitize_html_class( '' === $section ? 'default' : $section ) ),
- esc_attr( $context->get_page_id() ),
- esc_attr( $section )
- );
- return;
+ $this->log_settings_ui_fallback( $page_id, $section, $schema_failure_reason );
+ } else {
+ $mount_id = 'wc_settings_ui_' . sanitize_html_class( $this->id ) . '_' . sanitize_html_class( '' === $section ? 'default' : $section );
+
+ $context->enqueue_script_handles();
+
+ if ( $context->has_script_handle_loading_failed() ) {
+ $this->log_settings_ui_fallback( $page_id, $section, $context->get_script_handles_failure_reason() );
+ } else {
+ $GLOBALS['hide_save_button'] = true;
+ $hide_save_button_overridden = true;
+
+ printf(
+ '<div id="%1$s" data-wc-settings-ui="1" data-wc-settings-page="%2$s" data-wc-settings-section="%3$s"></div>',
+ esc_attr( $mount_id ),
+ esc_attr( $page_id ),
+ esc_attr( $section )
+ );
+ return;
+ }
}
}
+ } catch ( \Throwable $e ) {
+ // A stale or unavailable Settings UI class must keep the classic renderer usable during updates.
+ if ( $hide_save_button_overridden ) {
+ if ( $hide_save_button_existed ) {
+ $GLOBALS['hide_save_button'] = $previous_hide_save_button;
+ } else {
+ unset( $GLOBALS['hide_save_button'] );
+ }
+ }
+
+ wc_get_logger()->error(
+ sprintf(
+ 'Settings UI rendering failed for page "%1$s" section "%2$s": %3$s: %4$s',
+ $this->id,
+ '' === $section ? 'default' : $section,
+ get_class( $e ),
+ $e->getMessage()
+ ),
+ array( 'source' => 'settings-ui' )
+ );
+
+ if ( $e instanceof \Exception ) {
+ wc_caught_exception( $e, __METHOD__ );
+ }
}
// We can't use "get_settings_for_section" here
diff --git a/plugins/woocommerce/includes/admin/views/html-admin-settings.php b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
index a11799577f3..08e0f53054d 100644
--- a/plugins/woocommerce/includes/admin/views/html-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
@@ -53,7 +53,7 @@ try {
$is_rendering_settings_ui_drill_down = $settings_ui_context->is_rendering_enabled()
&& $settings_ui_context->is_drill_down()
&& ! $settings_ui_context->has_schema_failed()
- && ! $settings_ui_context->has_script_handles_failed();
+ && ! $settings_ui_context->has_script_handle_loading_failed();
}
} catch ( \Throwable $e ) {
$settings_ui_context = null;
diff --git a/plugins/woocommerce/src/Admin/Settings/AGENTS.md b/plugins/woocommerce/src/Admin/Settings/AGENTS.md
index 11636d56587..d16fb8523c0 100644
--- a/plugins/woocommerce/src/Admin/Settings/AGENTS.md
+++ b/plugins/woocommerce/src/Admin/Settings/AGENTS.md
@@ -8,7 +8,7 @@ During a plugin update, a request can pair new files on disk with stale cached c
Rules for this package and its call sites:
-- Code reachable from `includes/`, templates, or hook callbacks must call `SettingsUIRequestContext` (and the other classes here) either through methods that exist since WooCommerce 10.9, or inside a `class_exists` check plus `try/catch (\Throwable)` that falls back to legacy rendering. 10.9.x is the oldest release shipping these classes, so it is the version a stale class can be. `is_drill_down()` and `get_settings_page()` do not exist in 10.9; the rest of the context's surface does.
+- Code reachable from `includes/`, templates, or hook callbacks must call `SettingsUIRequestContext` (and the other classes here) either through methods that exist since WooCommerce 10.9, or inside a `class_exists` check plus `try/catch (\Throwable)` that falls back to legacy rendering. 10.9.x is the oldest release shipping these classes, so it is the version a stale class can be. Methods added after 10.9 include `is_drill_down()`, `get_settings_page()`, `has_script_handle_loading_failed()`, `enqueue_script_handles()`, and `get_schema_failure_reason()`; calls to these methods must stay inside the guarded fallback boundary.
- Public and protected method signatures are additive only. Never remove or change one; deprecate and keep it working.
- Never delete or rename a class file that has shipped in a release. A stale classmap entry pointing at a missing file fatals inside the autoloader, where no guard can catch it.
- Never add a required method to an interface here. Stale implementers fail at class-link time, uncatchably. Add a concrete default to the `SettingsSection` base class instead, the way `SettingsSectionUIPageProviderInterface` was introduced.
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings.php b/plugins/woocommerce/src/Internal/Admin/Settings.php
index d6033f1ca8b..f2da74679ed 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings.php
@@ -431,14 +431,18 @@ class Settings {
return $settings;
}
- $schema = $context->get_schema();
- if ( ! is_array( $schema ) ) {
+ try {
+ $schema = $context->get_schema();
+ if ( ! is_array( $schema ) ) {
+ return $settings;
+ }
+
+ $page_id = $context->get_page_id();
+ $section_key = $context->get_current_section_key();
+ } catch ( \Throwable $e ) {
return $settings;
}
- $page_id = $context->get_page_id();
- $section_key = $context->get_current_section_key();
-
if ( ! isset( $settings['settingsUI'] ) || ! is_array( $settings['settingsUI'] ) ) {
$settings['settingsUI'] = array();
}
@@ -446,6 +450,8 @@ class Settings {
$settings['settingsUI'][ $page_id ] = array();
}
+ // PHP converts numeric-string array keys to integers. Keep groups as a JSON object for the client.
+ $schema['groups'] = (object) $schema['groups'];
$settings['settingsUI'][ $page_id ][ $section_key ] = $schema;
return $settings;
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php b/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
index 7099644ed5c..64f6f31195d 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
@@ -51,6 +51,7 @@ class LegacySettingsPageAdapter implements PublicSettingsUIPageInterface {
*
* @param string $section Section id. Empty string means the default section.
* @return array
+ * @throws \InvalidArgumentException When legacy settings contain duplicate group ids.
*/
public function get_schema( string $section ): array {
return SettingsUISchema::from_legacy_settings(
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIPages/ProductsSettingsPageAdapter.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIPages/ProductsSettingsPageAdapter.php
index c88062a42bc..4306597f2a1 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIPages/ProductsSettingsPageAdapter.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIPages/ProductsSettingsPageAdapter.php
@@ -29,80 +29,6 @@ final class ProductsSettingsPageAdapter extends LegacySettingsPageAdapter {
$schema['shell']['title'] = __( 'Product settings', 'woocommerce' );
- if ( '' === $section ) {
- $schema = $this->with_field_options(
- $schema,
- 'woocommerce_shop_page_id',
- $this->get_page_options()
- );
- }
-
return $schema;
}
-
- /**
- * Add options to a field in a schema.
- *
- * @param array $schema Schema.
- * @param string $field_id Field id.
- * @param array $options Field options.
- * @return array
- */
- private function with_field_options( array $schema, string $field_id, array $options ): array {
- if ( empty( $options ) || ! isset( $schema['groups'] ) || ! is_array( $schema['groups'] ) ) {
- return $schema;
- }
-
- foreach ( $schema['groups'] as $group_id => $group ) {
- if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
- continue;
- }
-
- foreach ( $group['fields'] as $field_index => $field ) {
- if ( ! is_array( $field ) || ( $field['id'] ?? null ) !== $field_id ) {
- continue;
- }
-
- $schema['groups'][ $group_id ]['fields'][ $field_index ]['options'] = $options;
- return $schema;
- }
- }
-
- return $schema;
- }
-
- /**
- * Build the page options for the shop page selector.
- *
- * @return array<int, array{label: string, value: string}>
- */
- private function get_page_options(): array {
- $pages = get_pages(
- array(
- 'sort_column' => 'menu_order',
- 'sort_order' => 'ASC',
- 'post_status' => array( 'publish', 'private', 'draft' ),
- )
- );
-
- $options = array(
- array(
- 'label' => __( 'Select a page...', 'woocommerce' ),
- 'value' => '',
- ),
- );
-
- if ( ! is_array( $pages ) ) {
- return $options;
- }
-
- foreach ( $pages as $page ) {
- $options[] = array(
- 'label' => wp_strip_all_tags( $page->post_title ),
- 'value' => (string) $page->ID,
- );
- }
-
- return $options;
- }
}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
index 87506524993..3c986a0c423 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
@@ -34,6 +34,13 @@ class SettingsUIRequestContext {
*/
private const DRILL_DOWN_TABS = array( 'checkout' );
+ /**
+ * Query argument used to request classic settings for the current request.
+ *
+ * @var string
+ */
+ private const CLASSIC_REQUEST_QUERY_ARG = 'wc_settings_ui';
+
/**
* Context instances keyed by settings page object and section.
*
@@ -62,6 +69,27 @@ class SettingsUIRequestContext {
*/
private ?SettingsUIPageInterface $settings_ui_page;
+ /**
+ * Whether the Settings UI page id has been resolved.
+ *
+ * @var bool
+ */
+ private bool $page_id_resolved = false;
+
+ /**
+ * Resolved Settings UI page id.
+ *
+ * @var string
+ */
+ private string $page_id = '';
+
+ /**
+ * Failure raised while resolving the Settings UI page id.
+ *
+ * @var \Throwable|null
+ */
+ private ?\Throwable $page_id_failure = null;
+
/**
* Whether script handles have been resolved.
*
@@ -76,6 +104,13 @@ class SettingsUIRequestContext {
*/
private array $script_handles = array();
+ /**
+ * Whether script handle registrations have been checked.
+ *
+ * @var bool
+ */
+ private bool $script_handle_registrations_checked = false;
+
/**
* Whether script handle resolution failed.
*
@@ -111,6 +146,13 @@ class SettingsUIRequestContext {
*/
private bool $schema_failed = false;
+ /**
+ * Developer-facing schema failure reason.
+ *
+ * @var string
+ */
+ private string $schema_failure_reason = '';
+
/**
* Constructor.
*
@@ -120,7 +162,7 @@ class SettingsUIRequestContext {
private function __construct( \WC_Settings_Page $settings_page, string $section ) {
$this->settings_page = $settings_page;
$this->section = $section;
- $this->settings_ui_page = self::resolve_settings_ui_page( $settings_page, $section );
+ $this->settings_ui_page = self::is_classic_request() ? null : self::resolve_settings_ui_page( $settings_page, $section );
}
/**
@@ -129,7 +171,7 @@ class SettingsUIRequestContext {
* @return SettingsUIRequestContext|null
*/
public static function get_current(): ?SettingsUIRequestContext {
- if ( ! PageController::is_settings_page() || ! Features::is_enabled( 'settings-ui' ) || ! current_user_can( 'manage_woocommerce' ) ) {
+ if ( self::is_classic_request() || ! PageController::is_settings_page() || ! Features::is_enabled( 'settings-ui' ) || ! current_user_can( 'manage_woocommerce' ) ) {
return null;
}
@@ -259,9 +301,24 @@ class SettingsUIRequestContext {
* Get the Settings UI page id.
*
* @return string
+ * @throws \Throwable When the Settings UI page adapter cannot resolve its page id.
*/
public function get_page_id(): string {
- return $this->settings_ui_page ? $this->settings_ui_page->get_page_id() : $this->settings_page->get_id();
+ if ( ! $this->page_id_resolved ) {
+ try {
+ $this->page_id = $this->settings_ui_page ? $this->settings_ui_page->get_page_id() : $this->settings_page->get_id();
+ } catch ( \Throwable $e ) {
+ $this->page_id_failure = $e;
+ }
+
+ $this->page_id_resolved = true;
+ }
+
+ if ( $this->page_id_failure ) {
+ throw $this->page_id_failure;
+ }
+
+ return $this->page_id;
}
/**
@@ -308,6 +365,49 @@ class SettingsUIRequestContext {
return $this->script_handles;
}
+ /**
+ * Validate and enqueue extension script handles for this context.
+ *
+ * Handle names are collected separately so extensions can register their
+ * scripts after WooCommerce builds the settings embed dependency list.
+ *
+ * @since 11.2.0
+ *
+ * @return string[] Enqueued script handles, or an empty array on failure.
+ */
+ public function enqueue_script_handles(): array {
+ $this->validate_script_handle_registrations();
+
+ if ( $this->script_handles_failed ) {
+ return array();
+ }
+
+ try {
+ foreach ( $this->script_handles as $script_handle ) {
+ wp_enqueue_script( $script_handle );
+
+ if ( ! wp_script_is( $script_handle, 'enqueued' ) ) {
+ $this->record_script_handles_failure(
+ new \RuntimeException(
+ sprintf(
+ /* translators: %s: script handle. */
+ __( 'Settings UI script handle "%s" could not be enqueued.', 'woocommerce' ),
+ sanitize_text_field( $script_handle )
+ )
+ ),
+ __METHOD__
+ );
+ return array();
+ }
+ }
+ } catch ( \Throwable $e ) {
+ $this->record_script_handles_failure( $e, __METHOD__ );
+ return array();
+ }
+
+ return $this->script_handles;
+ }
+
/**
* Whether script handle resolution failed.
*
@@ -321,6 +421,19 @@ class SettingsUIRequestContext {
return $this->script_handles_failed;
}
+ /**
+ * Whether resolving, validating, or enqueueing declared scripts failed.
+ *
+ * @return bool
+ *
+ * @since 11.2.0
+ */
+ public function has_script_handle_loading_failed(): bool {
+ $this->validate_script_handle_registrations();
+
+ return $this->script_handles_failed;
+ }
+
/**
* Get the script handle failure reason.
*
@@ -342,6 +455,12 @@ class SettingsUIRequestContext {
* @return array|null
*/
public function get_schema(): ?array {
+ if ( $this->has_script_handles_failed() ) {
+ $this->schema_resolved = true;
+
+ return null;
+ }
+
if ( ! $this->schema_resolved ) {
$this->resolve_schema();
}
@@ -356,12 +475,46 @@ class SettingsUIRequestContext {
*/
public function has_schema_failed(): bool {
if ( ! $this->schema_resolved ) {
- $this->resolve_schema();
+ $this->get_schema();
}
return $this->schema_failed;
}
+ /**
+ * Get the schema failure reason.
+ *
+ * @return string
+ *
+ * @since 11.2.0
+ */
+ public function get_schema_failure_reason(): string {
+ if ( ! $this->schema_resolved ) {
+ $this->get_schema();
+ }
+
+ return '' !== $this->schema_failure_reason
+ ? $this->schema_failure_reason
+ : __( 'Settings UI schema could not be resolved.', 'woocommerce' );
+ }
+
+ /**
+ * Whether the current request explicitly asks for classic settings.
+ *
+ * @return bool
+ */
+ private static function is_classic_request(): bool {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only request override that changes rendering only.
+ if ( ! isset( $_GET[ self::CLASSIC_REQUEST_QUERY_ARG ] ) ) {
+ return false;
+ }
+
+ $rendering_mode = wp_unslash( $_GET[ self::CLASSIC_REQUEST_QUERY_ARG ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Type checked and sanitized below.
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ return is_string( $rendering_mode ) && 'classic' === sanitize_key( $rendering_mode );
+ }
+
/**
* Get the context cache key.
*
@@ -443,22 +596,88 @@ class SettingsUIRequestContext {
}
try {
- $this->script_handles = self::filter_script_handles( $this->settings_ui_page->get_script_handles( $this->section ) );
+ $this->script_handles = self::validate_script_handles( $this->settings_ui_page->get_script_handles( $this->section ) );
} catch ( \Throwable $e ) {
- $this->script_handles_failed = true;
+ $this->record_script_handles_failure( $e, __METHOD__ );
+ }
+ }
+
+ /**
+ * Validate extension script handle declarations.
+ *
+ * @param array $script_handles Declared script handles.
+ * @return string[] Validated script handles.
+ * @throws \InvalidArgumentException When a handle is not a non-empty string.
+ */
+ private static function validate_script_handles( array $script_handles ): array {
+ // Exception messages are cached diagnostics rather than HTML output. Dynamic
+ // handles are sanitized before the exception crosses this boundary.
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ foreach ( $script_handles as $script_handle ) {
+ if ( ! is_string( $script_handle ) || '' === trim( $script_handle ) ) {
+ throw new \InvalidArgumentException( __( 'Settings UI script handles must be non-empty strings.', 'woocommerce' ) );
+ }
+ }
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+
+ return array_values( array_unique( array_map( 'trim', $script_handles ) ) );
+ }
+
+ /**
+ * Validate extension script handle registrations.
+ */
+ private function validate_script_handle_registrations(): void {
+ if ( ! $this->script_handles_resolved ) {
+ $this->resolve_script_handles();
+ }
- self::log_resolution_failure( 'Settings UI script handles', $this->get_page_id(), $this->section, $e, __METHOD__ );
+ if ( $this->script_handle_registrations_checked || $this->script_handles_failed ) {
+ return;
+ }
+
+ $this->script_handle_registrations_checked = true;
- if ( $e instanceof \Exception ) {
- $this->script_handles_failure_reason = sprintf(
- /* translators: %s: exception message. */
- __( 'Settings UI script handles could not be resolved: %s', 'woocommerce' ),
- $e->getMessage()
+ try {
+ foreach ( $this->script_handles as $script_handle ) {
+ if ( wp_script_is( $script_handle, 'registered' ) ) {
+ continue;
+ }
+
+ $this->record_script_handles_failure(
+ new \RuntimeException(
+ sprintf(
+ /* translators: %s: script handle. */
+ __( 'Settings UI script handle "%s" is not registered.', 'woocommerce' ),
+ sanitize_text_field( $script_handle )
+ )
+ ),
+ __METHOD__
);
+ return;
}
+ } catch ( \Throwable $e ) {
+ $this->record_script_handles_failure( $e, __METHOD__ );
}
}
+ /**
+ * Cache and report a script handle failure.
+ *
+ * @param \Throwable $e Resolution failure.
+ * @param string $caller Calling method, for exception tracking.
+ */
+ private function record_script_handles_failure( \Throwable $e, string $caller ): void {
+ $this->script_handles_failed = true;
+
+ self::log_resolution_failure( 'Settings UI script handles', $this->settings_page->get_id(), $this->section, $e, $caller );
+
+ $this->script_handles_failure_reason = sprintf(
+ /* translators: %s: failure reason. */
+ __( 'Settings UI script handles could not be resolved: %s', 'woocommerce' ),
+ self::sanitize_failure_reason( $e )
+ );
+ }
+
/**
* Resolve the Settings UI schema.
*/
@@ -471,18 +690,34 @@ class SettingsUIRequestContext {
}
try {
- $schema = $this->settings_ui_page->get_schema( $this->section );
- $schema = SettingsUISchema::canonicalize_option_values( $schema );
- $schema = $this->apply_section_navigation( $schema );
- $schema = $this->apply_shell_header_visibility( $schema );
- $this->schema = $this->ensure_drill_down_breadcrumbs( $schema );
+ $schema = $this->settings_ui_page->get_schema( $this->section );
+ $schema = SettingsUISchema::canonicalize_option_values( $schema );
+ $schema = $this->apply_section_navigation( $schema );
+ $schema = $this->apply_shell_header_visibility( $schema );
+ $schema = $this->ensure_drill_down_breadcrumbs( $schema );
+
+ SettingsUISchema::assert_valid_schema( $schema );
+ $this->schema = $schema;
} catch ( \Throwable $e ) {
- $this->schema_failed = true;
+ $this->schema_failed = true;
+ $this->schema_failure_reason = self::sanitize_failure_reason( $e );
- self::log_resolution_failure( 'Settings UI schema', $this->get_page_id(), $this->section, $e, __METHOD__ );
+ self::log_resolution_failure( 'Settings UI schema', $this->settings_page->get_id(), $this->section, $e, __METHOD__ );
}
}
+ /**
+ * Sanitize a failure reason before it reaches developer-facing output.
+ *
+ * @param \Throwable $e Resolution failure.
+ * @return string
+ */
+ private static function sanitize_failure_reason( \Throwable $e ): string {
+ $reason = sanitize_text_field( $e->getMessage() );
+
+ return '' !== $reason ? $reason : get_class( $e );
+ }
+
/**
* Log a Settings UI resolution failure for developers.
*
@@ -493,7 +728,7 @@ class SettingsUIRequestContext {
* @param string $caller Calling method, for exception tracking.
*/
private static function log_resolution_failure( string $subject, string $page_id, string $section, \Throwable $e, string $caller ): void {
- wc_get_logger()->debug(
+ wc_get_logger()->error(
sprintf(
'%1$s could not be resolved for page "%2$s" section "%3$s": %4$s: %5$s',
$subject,
@@ -583,21 +818,4 @@ class SettingsUIRequestContext {
return $schema;
}
-
- /**
- * Filter extension-provided script handles to valid WordPress script handle strings.
- *
- * @param array $script_handles Raw script handles.
- * @return string[]
- */
- private static function filter_script_handles( array $script_handles ): array {
- return array_values(
- array_filter(
- $script_handles,
- static function ( $script_handle ): bool {
- return is_string( $script_handle ) && '' !== $script_handle;
- }
- )
- );
- }
}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
index 2675a4d886b..2720114e301 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
@@ -36,9 +36,11 @@ class SettingsUISchema {
* @param array $settings Legacy settings definitions.
* @param string $default_save_adapter Default save adapter.
* @return array
+ * @throws \InvalidArgumentException When legacy settings contain duplicate group ids.
*/
public static function from_legacy_settings( string $page_id, string $section, string $title, array $settings, string $default_save_adapter = 'form_post' ): array {
$groups = array();
+ $declared_group_ids = self::get_declared_group_ids( $settings );
$current_group = null;
$current_id = null;
$group_index = 0;
@@ -53,13 +55,13 @@ class SettingsUISchema {
if ( 'title' === $type ) {
$visibility_controller = null;
- if ( $current_group && $current_id ) {
- $groups[ $current_id ] = $current_group;
+ if ( null !== $current_group && null !== $current_id ) {
+ self::add_group( $groups, $current_id, $current_group );
}
$current_id = isset( $setting['id'] ) && is_scalar( $setting['id'] ) && '' !== (string) $setting['id']
? (string) $setting['id']
- : 'group_' . $group_index;
+ : self::get_unique_group_id( 'group_' . $group_index, $groups, $declared_group_ids );
$current_group = array(
'id' => $current_id,
'title' => isset( $setting['title'] ) && is_scalar( $setting['title'] ) ? html_entity_decode( (string) $setting['title'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) : '',
@@ -74,8 +76,8 @@ class SettingsUISchema {
if ( 'sectionend' === $type ) {
$visibility_controller = null;
- if ( $current_group && $current_id ) {
- $groups[ $current_id ] = $current_group;
+ if ( null !== $current_group && null !== $current_id ) {
+ self::add_group( $groups, $current_id, $current_group );
}
$current_group = null;
$current_id = null;
@@ -87,8 +89,8 @@ class SettingsUISchema {
}
if ( ! $current_group ) {
- $current_id = self::DEFAULT_GROUP_ID;
- $current_group = self::get_default_group( $group_index );
+ $current_id = self::get_unique_group_id( self::DEFAULT_GROUP_ID, $groups, $declared_group_ids );
+ $current_group = self::get_default_group( $current_id, $group_index );
++$group_index;
}
@@ -106,8 +108,8 @@ class SettingsUISchema {
}
}
- if ( $current_group && $current_id ) {
- $groups[ $current_id ] = $current_group;
+ if ( null !== $current_group && null !== $current_id ) {
+ self::add_group( $groups, $current_id, $current_group );
}
uasort(
@@ -138,6 +140,99 @@ class SettingsUISchema {
);
}
+ // Exception messages are not HTML output. Dynamic values are sanitized once
+ // by invalid_schema() before the exception crosses the schema boundary.
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+
+ /**
+ * Assert that a schema can safely cross the PHP-to-JavaScript boundary.
+ *
+ * Compatibility normalization and request-owned shell defaults must run
+ * before this assertion. An invalid schema throws before it can be cached or
+ * emitted to JavaScript.
+ *
+ * @since 11.2.0
+ *
+ * @param array $schema Settings UI schema.
+ * @throws \InvalidArgumentException When the schema is malformed.
+ */
+ public static function assert_valid_schema( array $schema ): void {
+ self::assert_non_empty_string( $schema['id'] ?? null, 'Schema id must be a non-empty string.' );
+
+ foreach ( array( 'title', 'section' ) as $property ) {
+ if ( array_key_exists( $property, $schema ) && ! is_string( $schema[ $property ] ) ) {
+ throw self::invalid_schema( sprintf( 'Schema %s must be a string.', $property ) );
+ }
+ }
+
+ if ( array_key_exists( 'section', $schema ) && '' === $schema['section'] ) {
+ throw self::invalid_schema( 'Schema section must be a non-empty string.' );
+ }
+
+ self::assert_page_save_strategy( $schema['save'] ?? null );
+ self::assert_shell( $schema['shell'] ?? null );
+
+ if ( ! isset( $schema['groups'] ) || ! is_array( $schema['groups'] ) ) {
+ throw self::invalid_schema( 'Schema groups must be a map.' );
+ }
+
+ foreach ( $schema['groups'] as $group_key => $group ) {
+ $group_id = (string) $group_key;
+ if ( '' === $group_id ) {
+ throw self::invalid_schema( 'Group map keys must be non-empty strings.' );
+ }
+
+ if ( ! is_array( $group ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" must be an array.', $group_id ) );
+ }
+
+ self::assert_non_empty_string( $group['id'] ?? null, sprintf( 'Group "%s" id must be a non-empty string.', $group_id ) );
+ if ( $group_id !== $group['id'] ) {
+ throw self::invalid_schema( sprintf( 'Group map key "%s" must match group id "%s".', $group_id, $group['id'] ) );
+ }
+ }
+
+ $field_ids = array();
+ $visibility_rules = array();
+ foreach ( $schema['groups'] as $group ) {
+ $group_id = $group['id'];
+ self::assert_optional_strings( $group, array( 'title', 'description' ), sprintf( 'Group "%s"', $group_id ) );
+ self::assert_group_actions( $group['actions'] ?? null, $group_id );
+
+ if ( ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) || ! ArrayUtil::array_is_list( $group['fields'] ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" fields must be a list.', $group_id ) );
+ }
+
+ foreach ( $group['fields'] as $field_index => $field ) {
+ if ( ! is_array( $field ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" field %d must be an array.', $group_id, $field_index ) );
+ }
+
+ self::assert_non_empty_string( $field['id'] ?? null, sprintf( 'Group "%s" field %d id must be a non-empty string.', $group_id, $field_index ) );
+ $field_id = $field['id'];
+ if ( isset( $field_ids[ $field_id ] ) ) {
+ throw self::invalid_schema( sprintf( 'Field id "%s" is duplicated.', $field_id ) );
+ }
+ if ( isset( $schema['groups'][ $field_id ] ) ) {
+ throw self::invalid_schema( sprintf( 'Field id "%s" collides with a group id.', $field_id ) );
+ }
+
+ $field_ids[ $field_id ] = true;
+ self::assert_field( $field );
+ if ( isset( $field['visibility'] ) ) {
+ $visibility_rules[ $field_id ] = $field['visibility'];
+ }
+ }
+ }
+
+ foreach ( $visibility_rules as $field_id => $visibility ) {
+ $controller = $visibility['controller'];
+ if ( ! isset( $field_ids[ $controller ] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" visibility controller "%s" does not reference a field.', $field_id, $controller ) );
+ }
+ }
+ }
+
/**
* Canonicalize option values supplied by native Settings UI schema providers.
*
@@ -368,9 +463,11 @@ class SettingsUISchema {
$field['options'] = $options;
}
- if ( 'info' === $type && '' === $field['description'] && isset( $setting['text'] ) && is_scalar( $setting['text'] ) ) {
- $field['description'] = wp_kses_post( (string) $setting['text'] );
- $field['save'] = array( 'adapter' => 'none' );
+ if ( 'info' === $type ) {
+ if ( '' === $field['description'] && isset( $setting['text'] ) && is_scalar( $setting['text'] ) ) {
+ $field['description'] = wp_kses_post( (string) $setting['text'] );
+ }
+ $field['save'] = array( 'adapter' => 'none' );
}
return $field;
@@ -537,12 +634,134 @@ class SettingsUISchema {
* @return array
*/
private static function get_options( array $setting ): array {
+ $type = isset( $setting['type'] ) && is_string( $setting['type'] ) ? $setting['type'] : '';
+
+ if ( 'single_select_page' === $type ) {
+ return self::get_page_options( $setting );
+ }
+
+ if ( 'single_select_country' === $type ) {
+ $countries = self::get_countries_controller();
+
+ return $countries ? self::get_country_and_state_options( $countries ) : array();
+ }
+
+ if ( 'multi_select_countries' === $type ) {
+ if ( ! isset( $setting['options'] ) || ! is_array( $setting['options'] ) || empty( $setting['options'] ) ) {
+ $countries_controller = self::get_countries_controller();
+ if ( ! $countries_controller ) {
+ return array();
+ }
+
+ $options = $countries_controller->get_countries();
+ } else {
+ $options = $setting['options'];
+ }
+
+ asort( $options );
+
+ return self::normalize_options( $options );
+ }
+
if ( ! isset( $setting['options'] ) || ! is_array( $setting['options'] ) ) {
return array();
}
+ return self::normalize_options( $setting['options'] );
+ }
+
+ /**
+ * Get the initialized WooCommerce countries controller.
+ *
+ * @return \WC_Countries|null
+ */
+ private static function get_countries_controller(): ?\WC_Countries {
+ if ( ! function_exists( 'WC' ) ) {
+ return null;
+ }
+
+ $woocommerce = WC();
+
+ return $woocommerce && $woocommerce->countries instanceof \WC_Countries ? $woocommerce->countries : null;
+ }
+
+ /**
+ * Build options for a legacy page selector.
+ *
+ * @param array $setting Legacy field definition.
+ * @return array
+ */
+ private static function get_page_options( array $setting ): array {
+ $args = array(
+ 'sort_column' => 'menu_order',
+ 'sort_order' => 'ASC',
+ 'post_status' => array( 'publish', 'private', 'draft' ),
+ );
+
+ if ( isset( $setting['args'] ) && is_array( $setting['args'] ) ) {
+ $args = wp_parse_args( $setting['args'], $args );
+ }
+
+ $options = array(
+ array(
+ 'label' => __( 'Select a page...', 'woocommerce' ),
+ 'value' => '',
+ ),
+ );
+
+ $pages = get_pages( $args );
+ if ( ! is_array( $pages ) ) {
+ return $options;
+ }
+
+ foreach ( $pages as $page ) {
+ $options[] = array(
+ 'label' => wp_strip_all_tags( $page->post_title ),
+ 'value' => (string) $page->ID,
+ );
+ }
+
+ return $options;
+ }
+
+ /**
+ * Build country and state options for a legacy country selector.
+ *
+ * @param \WC_Countries $countries Countries controller.
+ * @return array
+ */
+ private static function get_country_and_state_options( \WC_Countries $countries ): array {
+ $options = array();
+ foreach ( $countries->get_countries() as $country_code => $country_label ) {
+ $states = $countries->get_states( $country_code );
+ if ( $states ) {
+ foreach ( $states as $state_code => $state_label ) {
+ $options[] = array(
+ 'label' => wp_strip_all_tags( $country_label . ' — ' . $state_label ),
+ 'value' => $country_code . ':' . $state_code,
+ );
+ }
+ continue;
+ }
+
+ $options[] = array(
+ 'label' => wp_strip_all_tags( $country_label ),
+ 'value' => (string) $country_code,
+ );
+ }
+
+ return $options;
+ }
+
+ /**
+ * Normalize an option map.
+ *
+ * @param array $raw_options Raw option map.
+ * @return array
+ */
+ private static function normalize_options( array $raw_options ): array {
$options = array();
- foreach ( $setting['options'] as $value => $label ) {
+ foreach ( $raw_options as $value => $label ) {
if ( ! is_scalar( $label ) && null !== $label ) {
continue;
}
@@ -633,15 +852,427 @@ class SettingsUISchema {
return $actions;
}
+ // The assertion helpers deliberately propagate InvalidArgumentException to
+ // the public boundary method, whose contract documents that exception.
+ // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing
+
+ /**
+ * Assert page-level save metadata.
+ *
+ * @param mixed $save Save metadata, or null when omitted.
+ */
+ private static function assert_page_save_strategy( $save ): void {
+ if ( null === $save ) {
+ return;
+ }
+
+ if ( ! is_array( $save ) ) {
+ throw self::invalid_schema( 'Schema save strategy must be an array.' );
+ }
+
+ $adapter = $save['adapter'] ?? null;
+ if ( ! is_string( $adapter ) || ! in_array( $adapter, array( 'custom', 'form_post', 'none' ), true ) ) {
+ throw self::invalid_schema( 'Schema save adapter must be "custom", "form_post", or "none".' );
+ }
+
+ if ( 'custom' === $adapter ) {
+ self::assert_non_empty_string( $save['handler'] ?? null, 'Schema custom save strategy must define a non-empty handler.' );
+ } elseif ( array_key_exists( 'handler', $save ) ) {
+ throw self::invalid_schema( 'Schema save handler is only valid for the "custom" adapter.' );
+ }
+ }
+
+ /**
+ * Assert shell metadata.
+ *
+ * @param mixed $shell Shell metadata, or null when omitted.
+ */
+ private static function assert_shell( $shell ): void {
+ if ( null === $shell ) {
+ return;
+ }
+
+ if ( ! is_array( $shell ) ) {
+ throw self::invalid_schema( 'Schema shell must be an array.' );
+ }
+
+ self::assert_optional_strings( $shell, array( 'title', 'subtitle' ), 'Shell' );
+ if ( isset( $shell['header'] ) && ! in_array( $shell['header'], array( 'hidden', 'visible' ), true ) ) {
+ throw self::invalid_schema( 'Shell header must be "hidden" or "visible".' );
+ }
+
+ if ( array_key_exists( 'navigationComponent', $shell ) ) {
+ self::assert_non_empty_string( $shell['navigationComponent'], 'Shell navigationComponent must be a non-empty string.' );
+ }
+
+ foreach ( array( 'navigation', 'sectionNavigation' ) as $property ) {
+ if ( ! array_key_exists( $property, $shell ) ) {
+ continue;
+ }
+
+ self::assert_shell_navigation( $shell[ $property ], 'Shell ' . $property );
+ }
+
+ self::assert_shell_breadcrumbs( $shell['breadcrumbs'] ?? null );
+ self::assert_shell_badges( $shell['badges'] ?? null );
+ }
+
+ /**
+ * Assert shell navigation items.
+ *
+ * @param mixed $items Navigation items.
+ * @param string $context Error message context.
+ */
+ private static function assert_shell_navigation( $items, string $context ): void {
+ if ( ! is_array( $items ) || ! ArrayUtil::array_is_list( $items ) ) {
+ throw self::invalid_schema( $context . ' must be a list.' );
+ }
+
+ $ids = array();
+ foreach ( $items as $index => $item ) {
+ if ( ! is_array( $item ) ) {
+ throw self::invalid_schema( sprintf( '%s item %d must be an array.', $context, $index ) );
+ }
+
+ self::assert_non_empty_string( $item['id'] ?? null, sprintf( '%s item %d id must be a non-empty string.', $context, $index ) );
+ if ( isset( $ids[ $item['id'] ] ) ) {
+ throw self::invalid_schema( sprintf( '%s item id "%s" is duplicated.', $context, $item['id'] ) );
+ }
+ $ids[ $item['id'] ] = true;
+
+ foreach ( array( 'label', 'href' ) as $property ) {
+ if ( ! isset( $item[ $property ] ) || ! is_string( $item[ $property ] ) ) {
+ throw self::invalid_schema( sprintf( '%s item %d %s must be a string.', $context, $index, $property ) );
+ }
+ }
+
+ if ( isset( $item['active'] ) && ! is_bool( $item['active'] ) ) {
+ throw self::invalid_schema( sprintf( '%s item %d active must be a boolean.', $context, $index ) );
+ }
+ }
+ }
+
+ /**
+ * Assert shell breadcrumbs.
+ *
+ * @param mixed $breadcrumbs Breadcrumb metadata, or null when omitted.
+ */
+ private static function assert_shell_breadcrumbs( $breadcrumbs ): void {
+ if ( null === $breadcrumbs ) {
+ return;
+ }
+
+ if ( ! is_array( $breadcrumbs ) || ! ArrayUtil::array_is_list( $breadcrumbs ) ) {
+ throw self::invalid_schema( 'Shell breadcrumbs must be a list.' );
+ }
+
+ foreach ( $breadcrumbs as $index => $breadcrumb ) {
+ if ( ! is_array( $breadcrumb ) || ! isset( $breadcrumb['label'] ) || ! is_string( $breadcrumb['label'] ) ) {
+ throw self::invalid_schema( sprintf( 'Shell breadcrumb %d label must be a string.', $index ) );
+ }
+
+ if ( isset( $breadcrumb['href'] ) && ! is_string( $breadcrumb['href'] ) ) {
+ throw self::invalid_schema( sprintf( 'Shell breadcrumb %d href must be a string.', $index ) );
+ }
+ }
+ }
+
+ /**
+ * Assert shell badges.
+ *
+ * @param mixed $badges Badge metadata, or null when omitted.
+ */
+ private static function assert_shell_badges( $badges ): void {
+ if ( null === $badges ) {
+ return;
+ }
+
+ if ( ! is_array( $badges ) || ! ArrayUtil::array_is_list( $badges ) ) {
+ throw self::invalid_schema( 'Shell badges must be a list.' );
+ }
+
+ foreach ( $badges as $index => $badge ) {
+ if ( ! is_array( $badge ) || ! isset( $badge['label'] ) || ! is_string( $badge['label'] ) ) {
+ throw self::invalid_schema( sprintf( 'Shell badge %d label must be a string.', $index ) );
+ }
+
+ if ( isset( $badge['intent'] ) && ! is_string( $badge['intent'] ) ) {
+ throw self::invalid_schema( sprintf( 'Shell badge %d intent must be a string.', $index ) );
+ }
+ }
+ }
+
+ /**
+ * Assert group header actions.
+ *
+ * @param mixed $actions Group actions, or null when omitted.
+ * @param string $group_id Group id.
+ */
+ private static function assert_group_actions( $actions, string $group_id ): void {
+ if ( null === $actions ) {
+ return;
+ }
+
+ if ( ! is_array( $actions ) || ! ArrayUtil::array_is_list( $actions ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" actions must be a list.', $group_id ) );
+ }
+
+ $ids = array();
+ foreach ( $actions as $index => $action ) {
+ if ( ! is_array( $action ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" action %d must be an array.', $group_id, $index ) );
+ }
+
+ self::assert_non_empty_string( $action['id'] ?? null, sprintf( 'Group "%s" action %d id must be a non-empty string.', $group_id, $index ) );
+ if ( isset( $ids[ $action['id'] ] ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" action id "%s" is duplicated.', $group_id, $action['id'] ) );
+ }
+ $ids[ $action['id'] ] = true;
+
+ foreach ( array( 'label', 'href' ) as $property ) {
+ if ( ! isset( $action[ $property ] ) || ! is_string( $action[ $property ] ) ) {
+ throw self::invalid_schema( sprintf( 'Group "%s" action %d %s must be a string.', $group_id, $index, $property ) );
+ }
+ }
+
+ self::assert_optional_strings( $action, array( 'variant', 'target', 'rel' ), sprintf( 'Group "%s" action %d', $group_id, $index ) );
+ }
+ }
+
+ /**
+ * Assert a field definition.
+ *
+ * @param array $field Field definition.
+ */
+ private static function assert_field( array $field ): void {
+ $field_id = $field['id'];
+ if ( ! isset( $field['label'] ) || ! is_string( $field['label'] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" label must be a string.', $field_id ) );
+ }
+
+ $type = $field['type'] ?? null;
+ self::assert_non_empty_string( $type, sprintf( 'Field "%s" type must be a non-empty string.', $field_id ) );
+
+ self::assert_optional_strings( $field, array( 'description', 'placeholder' ), sprintf( 'Field "%s"', $field_id ) );
+ if ( isset( $field['disabled'] ) && ! is_bool( $field['disabled'] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" disabled must be a boolean.', $field_id ) );
+ }
+
+ if ( array_key_exists( 'component', $field ) ) {
+ self::assert_non_empty_string( $field['component'], sprintf( 'Field "%s" component must be a non-empty string.', $field_id ) );
+ }
+
+ self::assert_field_value( $field );
+ self::assert_field_options( $field );
+ self::assert_custom_attributes( $field );
+ self::assert_field_save( $field );
+ self::assert_visibility( $field );
+ }
+
+ /**
+ * Assert a field value when supplied.
+ *
+ * @param array $field Field definition.
+ */
+ private static function assert_field_value( array $field ): void {
+ if ( ! array_key_exists( 'value', $field ) ) {
+ return;
+ }
+
+ if ( ! self::is_settings_value( $field['value'] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" value is not a valid Settings UI value.', $field['id'] ) );
+ }
+ }
+
+ /**
+ * Assert choice options.
+ *
+ * @param array $field Field definition.
+ */
+ private static function assert_field_options( array $field ): void {
+ if ( ! array_key_exists( 'options', $field ) ) {
+ return;
+ }
+
+ $field_id = $field['id'];
+ $options = $field['options'];
+
+ if ( ! is_array( $options ) || ! ArrayUtil::array_is_list( $options ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" options must be a list.', $field_id ) );
+ }
+
+ foreach ( $options as $index => $option ) {
+ if ( ! is_array( $option ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" option %d must be an array.', $field_id, $index ) );
+ }
+
+ foreach ( array( 'label', 'value' ) as $property ) {
+ if ( ! isset( $option[ $property ] ) || ! is_string( $option[ $property ] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" option %d %s must be a string.', $field_id, $index, $property ) );
+ }
+ }
+ }
+ }
+
+ /**
+ * Assert custom input attributes.
+ *
+ * @param array $field Field definition.
+ */
+ private static function assert_custom_attributes( array $field ): void {
+ if ( ! array_key_exists( 'customAttributes', $field ) ) {
+ return;
+ }
+
+ if ( ! is_array( $field['customAttributes'] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" customAttributes must be a map.', $field['id'] ) );
+ }
+
+ foreach ( $field['customAttributes'] as $attribute => $value ) {
+ if ( ! is_string( $attribute ) || '' === $attribute ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" custom attribute names must be non-empty strings.', $field['id'] ) );
+ }
+
+ if ( ! is_scalar( $value ) || ( is_float( $value ) && ! is_finite( $value ) ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" custom attribute "%s" has an invalid value.', $field['id'], $attribute ) );
+ }
+ }
+ }
+
+ /**
+ * Assert field save metadata.
+ *
+ * @param array $field Field definition.
+ */
+ private static function assert_field_save( array $field ): void {
+ $save = $field['save'] ?? null;
+ if ( null !== $save && ! is_array( $save ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" save metadata must be an array.', $field['id'] ) );
+ }
+
+ $adapter = is_array( $save ) ? ( $save['adapter'] ?? null ) : 'form_post';
+ if ( ! is_string( $adapter ) || ! in_array( $adapter, array( 'form_post', 'none' ), true ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" save adapter must be "form_post" or "none".', $field['id'] ) );
+ }
+
+ if ( is_array( $save ) && array_key_exists( 'name', $save ) ) {
+ self::assert_non_empty_string( $save['name'], sprintf( 'Field "%s" save name must be a non-empty string.', $field['id'] ) );
+ }
+
+ if ( 'info' === $field['type'] && 'none' !== $adapter ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" of type "info" must use the "none" save adapter.', $field['id'] ) );
+ }
+ }
+
+ /**
+ * Assert field visibility metadata.
+ *
+ * @param array $field Field definition.
+ */
+ private static function assert_visibility( array $field ): void {
+ if ( ! array_key_exists( 'visibility', $field ) ) {
+ return;
+ }
+
+ $visibility = $field['visibility'];
+ if ( ! is_array( $visibility ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" visibility must be an array.', $field['id'] ) );
+ }
+
+ self::assert_non_empty_string( $visibility['controller'] ?? null, sprintf( 'Field "%s" visibility controller must be a non-empty string.', $field['id'] ) );
+ if ( array_key_exists( 'value', $visibility ) && ! self::is_visibility_value( $visibility['value'] ) ) {
+ throw self::invalid_schema( sprintf( 'Field "%s" visibility value is invalid.', $field['id'] ) );
+ }
+ }
+
+ /**
+ * Whether a value is representable by the visibility rule contract.
+ *
+ * Visibility rules accept either one settings value or a list of settings
+ * values. String lists are valid in both positions and are disambiguated by
+ * the renderer at comparison time.
+ *
+ * @param mixed $value Candidate value.
+ * @return bool
+ */
+ private static function is_visibility_value( $value ): bool {
+ if ( self::is_settings_value( $value ) ) {
+ return true;
+ }
+
+ return is_array( $value )
+ && ArrayUtil::array_is_list( $value )
+ && count( $value ) === count( array_filter( $value, array( __CLASS__, 'is_settings_value' ) ) );
+ }
+
+ /**
+ * Whether a value is representable by the Settings UI state contract.
+ *
+ * @param mixed $value Candidate value.
+ * @return bool
+ */
+ private static function is_settings_value( $value ): bool {
+ if ( null === $value || is_string( $value ) || is_bool( $value ) || is_int( $value ) ) {
+ return true;
+ }
+
+ if ( is_float( $value ) ) {
+ return is_finite( $value );
+ }
+
+ return is_array( $value ) && ArrayUtil::array_is_list( $value ) && count( $value ) === count( array_filter( $value, 'is_string' ) );
+ }
+
+ /**
+ * Assert optional string properties.
+ *
+ * @param array $value Candidate container.
+ * @param string[] $properties Property names.
+ * @param string $context Error message context.
+ */
+ private static function assert_optional_strings( array $value, array $properties, string $context ): void {
+ foreach ( $properties as $property ) {
+ if ( array_key_exists( $property, $value ) && ! is_string( $value[ $property ] ) ) {
+ throw self::invalid_schema( sprintf( '%s %s must be a string.', $context, $property ) );
+ }
+ }
+ }
+
+ /**
+ * Assert a non-empty string.
+ *
+ * @param mixed $value Candidate value.
+ * @param string $message Exception message.
+ */
+ private static function assert_non_empty_string( $value, string $message ): void {
+ if ( ! is_string( $value ) || '' === $value ) {
+ throw self::invalid_schema( $message );
+ }
+ }
+
+ /**
+ * Build a safely escaped schema validation exception.
+ *
+ * @param string $message Developer-facing validation reason.
+ * @return \InvalidArgumentException
+ */
+ private static function invalid_schema( string $message ): \InvalidArgumentException {
+ return new \InvalidArgumentException( sanitize_text_field( $message ) );
+ }
+
+ // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.Missing
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+
/**
* Get the default group.
*
- * @param int $order Group order.
+ * @param string $group_id Group id.
+ * @param int $order Group order.
* @return array
*/
- private static function get_default_group( int $order ): array {
+ private static function get_default_group( string $group_id, int $order ): array {
return array(
- 'id' => self::DEFAULT_GROUP_ID,
+ 'id' => $group_id,
'title' => '',
'description' => '',
'actions' => array(),
@@ -649,4 +1280,67 @@ class SettingsUISchema {
'fields' => array(),
);
}
+
+ /**
+ * Get explicit legacy group ids that generated ids must not claim.
+ *
+ * @param array $settings Legacy settings definitions.
+ * @return array<string, true>
+ */
+ private static function get_declared_group_ids( array $settings ): array {
+ $group_ids = array();
+
+ foreach ( $settings as $setting ) {
+ if (
+ is_array( $setting )
+ && 'title' === ( $setting['type'] ?? null )
+ && isset( $setting['id'] )
+ && is_scalar( $setting['id'] )
+ && '' !== (string) $setting['id']
+ ) {
+ $group_ids[ (string) $setting['id'] ] = true;
+ }
+ }
+
+ return $group_ids;
+ }
+
+ /**
+ * Get an unused id for a generated legacy group.
+ *
+ * @param string $base_id Base group id.
+ * @param array<string, array> $groups Existing groups.
+ * @param array<string, true> $declared_group_ids Explicit group ids.
+ * @return string
+ */
+ private static function get_unique_group_id( string $base_id, array $groups, array $declared_group_ids ): string {
+ $group_id = $base_id;
+ $suffix = 1;
+
+ while ( array_key_exists( $group_id, $groups ) || isset( $declared_group_ids[ $group_id ] ) ) {
+ $group_id = $base_id . '_' . $suffix;
+ ++$suffix;
+ }
+
+ return $group_id;
+ }
+
+ /**
+ * Add a legacy group without allowing a later group to overwrite it.
+ *
+ * @param array $groups Groups keyed by id.
+ * @param string $group_id Group id.
+ * @param array $group Group definition.
+ * @throws \InvalidArgumentException When the group id is duplicated.
+ */
+ private static function add_group( array &$groups, string $group_id, array $group ): void {
+ // Exception messages are sanitized by invalid_schema() before they cross the schema boundary.
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ if ( array_key_exists( $group_id, $groups ) ) {
+ throw self::invalid_schema( sprintf( 'Group id "%s" is duplicated.', $group_id ) );
+ }
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+
+ $groups[ $group_id ] = $group;
+ }
}
diff --git a/plugins/woocommerce/tests/e2e/playwright.config.ts b/plugins/woocommerce/tests/e2e/playwright.config.ts
index c23bff2c0a4..cdb040c204a 100644
--- a/plugins/woocommerce/tests/e2e/playwright.config.ts
+++ b/plugins/woocommerce/tests/e2e/playwright.config.ts
@@ -172,12 +172,8 @@ const serialRunSpecs = [
// that other workers' cart/checkout/storefront specs depend on.
'**/tests/settings/settings-general.spec.ts',
'**/tests/settings/settings-tax.spec.ts',
- // Unchecks and saves `woocommerce_enable_reviews`, flipping that global option
- // to `no` mid-run (restored only in afterAll). While off, the front-end Reviews
- // tab and admin review management disappear — proven to deterministically fail 3
- // `product/product-reviews.spec.ts` tests (shopper post + the edit/reply Reviews
- // tab assertions). Also toggles the global `settings-ui` feature flag and resets
- // ALL e2e feature flags in afterAll.
+ // Toggles the global `settings-ui` feature flag and resets all e2e feature flags
+ // in afterAll.
'**/tests/settings/settings-ui-feature-flag.spec.ts',
// Toggles the global `woocommerce_cart_redirect_after_add` setting, which
// changes add-to-cart behavior for every other worker — not parallel-safe.
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
new file mode 100644
index 00000000000..584bd39b428
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/test-plugins/settings-ui-component-registration/settings-ui-component-registration.php
@@ -0,0 +1,184 @@
+<?php
+/**
+ * Plugin Name: WooCommerce Settings UI Component Registration Test
+ * Description: Provides Settings UI component registration scenarios for end-to-end tests.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-settings-ui-component-registration-test
+ */
+
+declare( strict_types=1 );
+
+use Automattic\WooCommerce\Admin\Settings\SettingsSection;
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionRegistry;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the Settings UI test fixture.
+ */
+final class WC_Settings_UI_Component_Registration_Test_Plugin {
+
+ private const REGISTERED_HANDLE = 'settings-ui-component-test-registered';
+ private const MISSING_HANDLE = 'settings-ui-component-test-missing-registration';
+
+ /**
+ * Register hooks.
+ */
+ public static function init(): void {
+ add_action( 'admin_init', array( self::class, 'register_scripts' ) );
+ add_action( 'woocommerce_settings_sections_registration', array( self::class, 'register_sections' ) );
+ }
+
+ /**
+ * Register test scripts before Settings UI dependencies are resolved.
+ *
+ * @internal
+ */
+ public static function register_scripts(): void {
+ wp_register_script( self::REGISTERED_HANDLE, false, array( 'wc-settings-ui', 'wp-element' ), '1.0.0', true );
+ wp_add_inline_script(
+ self::REGISTERED_HANDLE,
+ <<<'JS'
+window.wcSettingsUI.registerSettingsExtension( {
+ scope: { page: 'products', section: 'settings_ui_component_registered' },
+ components: {
+ 'woocommerce/settings-ui-component-test': function SettingsUIComponentTest( props ) {
+ return window.wp.element.createElement(
+ 'label',
+ { 'data-testid': 'settings-ui-registered-component' },
+ '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 : '',
+ } )
+ );
+ },
+ },
+} );
+JS
+ );
+
+ wp_register_script( self::MISSING_HANDLE, false, array( 'wc-settings-ui' ), '1.0.0', true );
+ wp_add_inline_script(
+ self::MISSING_HANDLE,
+ 'window.wcSettingsUIComponentTest = window.wcSettingsUIComponentTest || {}; window.wcSettingsUIComponentTest.missingRegistrationScriptExecuted = true;'
+ );
+ }
+
+ /**
+ * Register the test settings sections.
+ *
+ * @internal
+ *
+ * @param SettingsSectionRegistry $registry Settings section registry.
+ */
+ public static function register_sections( SettingsSectionRegistry $registry ): void {
+ $registry->register( self::create_section( 'settings_ui_component_registered', self::REGISTERED_HANDLE ) );
+ $registry->register( self::create_section( 'settings_ui_component_missing', self::MISSING_HANDLE ) );
+ }
+
+ /**
+ * Create a Settings UI test section after WooCommerce has loaded its settings classes.
+ *
+ * @param string $section_id Section id.
+ * @param string $script_handle Declared script handle.
+ * @return SettingsSection
+ */
+ private static function create_section( string $section_id, string $script_handle ): SettingsSection {
+ return new class( $section_id, $script_handle ) extends SettingsSection {
+ /**
+ * Section id.
+ *
+ * @var string
+ */
+ private string $section_id;
+
+ /**
+ * Declared script handle.
+ *
+ * @var string
+ */
+ private string $script_handle;
+
+ /**
+ * Create a test section.
+ *
+ * @param string $section_id Section id.
+ * @param string $script_handle Declared script handle.
+ */
+ public function __construct( string $section_id, string $script_handle ) {
+ $this->section_id = $section_id;
+ $this->script_handle = $script_handle;
+ }
+
+ /**
+ * Get the parent page id.
+ *
+ * @return string
+ */
+ public function get_parent_page_id(): string {
+ return 'products';
+ }
+
+ /**
+ * Get the section id.
+ *
+ * @return string
+ */
+ public function get_id(): string {
+ return $this->section_id;
+ }
+
+ /**
+ * Get the section label.
+ *
+ * @return string
+ */
+ public function get_label(): string {
+ return 'Settings UI component test';
+ }
+
+ /**
+ * Get the section settings.
+ *
+ * @param WC_Settings_Page $parent_page Parent settings page.
+ * @return array
+ */
+ public function get_settings( WC_Settings_Page $parent_page ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by the settings section contract.
+ return array(
+ array(
+ 'id' => $this->section_id . '_group',
+ 'title' => 'Settings UI component test',
+ 'type' => 'title',
+ ),
+ array(
+ 'component' => 'woocommerce/settings-ui-component-test',
+ 'default' => 'Initial value',
+ 'id' => $this->section_id . '_value',
+ 'title' => 'Component value',
+ 'type' => 'text',
+ ),
+ array(
+ 'id' => $this->section_id . '_group',
+ 'type' => 'sectionend',
+ ),
+ );
+ }
+
+ /**
+ * Get the declared script handles.
+ *
+ * @param WC_Settings_Page $parent_page Parent settings page.
+ * @return string[]
+ */
+ public function get_script_handles( WC_Settings_Page $parent_page ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by the settings section contract.
+ return array( $this->script_handle );
+ }
+ };
+ }
+}
+
+WC_Settings_UI_Component_Registration_Test_Plugin::init();
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 12fe09a70cd..87d3d4da1e2 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
@@ -4,7 +4,7 @@
import { expect, test, tags, request } from '../../fixtures/fixtures';
import { ADMIN_STATE_PATH } from '../../playwright.config';
import { setFeatureFlag, resetFeatureFlags } from '../../utils/features';
-import { setOption } from '../../utils/options';
+import { wpCLI } from '../../utils/cli';
const compatibilityFailureFragments = [
'private api',
@@ -33,48 +33,29 @@ const getBaseURL = ( baseURL: string | undefined ): string => {
return baseURL;
};
-test.describe( 'Settings UI feature flag', { tag: tags.NOT_E2E }, () => {
+test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
test.use( { storageState: ADMIN_STATE_PATH } );
- test.beforeEach( async ( { baseURL } ) => {
+ test.beforeAll( async ( { baseURL } ) => {
const url = getBaseURL( baseURL );
- await setFeatureFlag( request, url, 'settings-ui', false );
- await setOption( request, url, 'woocommerce_enable_reviews', 'yes' );
+ await wpCLI(
+ 'wp plugin activate settings-ui-component-registration --skip-plugins'
+ );
+ await setFeatureFlag( request, url, 'settings-ui', true );
} );
test.afterAll( async ( { baseURL } ) => {
const url = getBaseURL( baseURL );
await resetFeatureFlags( request, url );
- await setOption( request, url, 'woocommerce_enable_reviews', 'yes' );
- } );
-
- test( 'does not mount the settings UI when the feature flag is disabled', async ( {
- page,
- } ) => {
- await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
-
- await expect(
- page.locator( '#woocommerce_enable_reviews' )
- ).toBeVisible();
- await expect( page.locator( '[data-wc-settings-ui]' ) ).toHaveCount(
- 0
+ await wpCLI(
+ 'wp plugin deactivate settings-ui-component-registration --skip-plugins'
);
- await page.locator( '#woocommerce_enable_reviews' ).uncheck();
- await page.getByRole( 'button', { name: 'Save changes' } ).click();
-
- await expect( page.locator( 'div.updated.inline' ) ).toContainText(
- 'Your settings have been saved.'
- );
- await expect(
- page.locator( '#woocommerce_enable_reviews' )
- ).not.toBeChecked();
} );
test( 'loads the private DataForm runtime without compatibility failures', async ( {
page,
- baseURL,
} ) => {
const compatibilityFailures: string[] = [];
const recordCompatibilityFailure = ( message: string ) => {
@@ -90,12 +71,6 @@ test.describe( 'Settings UI feature flag', { tag: tags.NOT_E2E }, () => {
recordCompatibilityFailure( message.text() );
} );
- await setFeatureFlag(
- request,
- getBaseURL( baseURL ),
- 'settings-ui',
- true
- );
await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
await expect( page.locator( '[data-wc-settings-ui]' ) ).toBeVisible();
await expect(
@@ -146,4 +121,71 @@ test.describe( 'Settings UI feature flag', { tag: tags.NOT_E2E }, () => {
expect( compatibilityFailures ).toEqual( [] );
} );
+
+ test( 'loads a declared component registration before mounting settings', async ( {
+ page,
+ } ) => {
+ await page.goto(
+ 'wp-admin/admin.php?page=wc-settings&tab=products§ion=settings_ui_component_registered'
+ );
+
+ await expect(
+ page.locator( '[data-wc-settings-ui="1"]' )
+ ).toBeVisible();
+ await expect(
+ page.getByTestId( 'settings-ui-registered-component' )
+ ).toContainText( 'Registered settings UI component' );
+ } );
+
+ test( 'fails closed when an executed script omits its component registration', async ( {
+ page,
+ } ) => {
+ const settingsUrl =
+ 'wp-admin/admin.php?page=wc-settings&tab=products§ion=settings_ui_component_missing&preserved=yes';
+
+ await page.goto( settingsUrl );
+
+ await expect( page.getByRole( 'textbox' ) ).toHaveCount( 0 );
+ await expect( page.locator( '.woocommerce-save-button' ) ).toHaveCount(
+ 0
+ );
+ const classicAction = page.getByRole( 'link', {
+ name: 'Use classic settings',
+ } );
+ await expect( classicAction ).toBeVisible();
+ expect(
+ await page.evaluate(
+ () =>
+ (
+ window as unknown as {
+ wcSettingsUIComponentTest?: {
+ missingRegistrationScriptExecuted?: boolean;
+ };
+ }
+ ).wcSettingsUIComponentTest
+ ?.missingRegistrationScriptExecuted
+ )
+ ).toBe( true );
+
+ await classicAction.click();
+ await expect( page ).toHaveURL( ( url ) => {
+ const params = url.searchParams;
+
+ return (
+ params.get( 'wc_settings_ui' ) === 'classic' &&
+ params.get( 'tab' ) === 'products' &&
+ params.get( 'section' ) === 'settings_ui_component_missing' &&
+ params.get( 'preserved' ) === 'yes'
+ );
+ } );
+ await expect(
+ page.locator( '#settings_ui_component_missing_value' )
+ ).toBeVisible();
+ await expect( page.locator( '[data-wc-settings-ui]' ) ).toHaveCount(
+ 0
+ );
+ await expect(
+ page.getByRole( 'button', { name: 'Save changes' } )
+ ).toBeVisible();
+ } );
} );
diff --git a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
index d59a2a458a0..9791b09c3ed 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
@@ -65,6 +65,10 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
$this->original_current_section = $current_section ?? null;
$this->original_current_tab = $current_tab ?? null;
+ foreach ( array( 'acme-native-settings-ui', 'acme-payments-settings-ui', 'direct-payments-settings-ui' ) as $script_handle ) {
+ wp_register_script( $script_handle, false, array(), '1.0.0', true );
+ }
+
SettingsSectionRegistry::get_instance()->unregister_all();
SettingsUIRequestContext::reset();
}
@@ -80,6 +84,10 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
$current_tab = $this->original_current_tab;
remove_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ foreach ( array( 'acme-native-settings-ui', 'acme-payments-settings-ui', 'direct-payments-settings-ui' ) as $script_handle ) {
+ wp_dequeue_script( $script_handle );
+ wp_deregister_script( $script_handle );
+ }
SettingsSectionRegistry::get_instance()->unregister_all();
SettingsUIRequestContext::reset();
@@ -135,6 +143,7 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
$this->assertSame( 'form_post', $settings_ui_page->get_save_adapter( 'acme_payments' ) );
$schema = $settings_ui_page->get_schema( 'acme_payments' );
+ SettingsUISchema::assert_valid_schema( $schema );
$this->assertSame( 'Acme Payments', $schema['title'] );
$this->assertSame( 'Acme Payments', $schema['shell']['title'] );
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
index 8b81b0887d7..27a3d685f46 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
@@ -9,9 +9,11 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\Admin\Settings;
+use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Settings;
use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
use WC_Unit_Test_Case;
/**
@@ -19,6 +21,8 @@ use WC_Unit_Test_Case;
*/
class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
+ use LoggerSpyTrait;
+
/**
* Original request globals.
*
@@ -122,6 +126,13 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
remove_filter( 'woocommerce_admin_features', array( $this, 'disable_settings_ui_feature' ) );
wp_dequeue_style( 'wc-settings-ui' );
wp_deregister_style( 'wc-settings-ui' );
+ wp_dequeue_script( 'settings-ui-counting-handle' );
+ wp_deregister_script( 'settings-ui-counting-handle' );
+ wp_dequeue_script( 'settings-ui-registered-handle' );
+ wp_deregister_script( 'settings-ui-registered-handle' );
+ wp_dequeue_script( 'settings-ui-late-registered-handle' );
+ wp_deregister_script( 'settings-ui-late-registered-handle' );
+ delete_option( 'woocommerce_settings_ui_flag_test' );
SettingsUIRequestContext::reset();
parent::tearDown();
@@ -247,7 +258,8 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
$this->assertSame( '10.9.0', $settings_page_notices[0]['version'] );
$this->assertStringContainsString( 'settings_ui_flag_test', $settings_page_notices[0]['message'] );
$this->assertStringContainsString( 'advanced', $settings_page_notices[0]['message'] );
- $this->assertStringContainsString( 'Settings UI schema generation failed.', $settings_page_notices[0]['message'] );
+ $this->assertStringContainsString( 'Unable to build settings UI schema.', $settings_page_notices[0]['message'] );
+ $this->assertLogged( 'error', 'Settings UI schema could not be resolved', array( 'source' => 'settings-ui' ) );
}
/**
@@ -255,10 +267,11 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
*/
public function test_settings_ui_script_handles_are_resolved_once_per_context(): void {
add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ wp_register_script( 'settings-ui-counting-handle', false, array(), '1.0.0', true );
global $current_section;
$current_section = '';
- $page = $this->get_settings_ui_test_page_with_counting_script_handles();
+ $page = $this->get_settings_ui_test_page_with_script_handles( array( 'settings-ui-counting-handle' ) );
$context = SettingsUIRequestContext::for_settings_page( $page, '' );
$this->assertSame( array( 'settings-ui-counting-handle' ), $context->get_script_handles() );
@@ -267,7 +280,248 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
$page->output();
ob_get_clean();
- $this->assertSame( 1, $this->get_script_handle_resolution_count( $page ), 'Script handles should be resolved once for a page and section context.' );
+ $this->assertSame( 1, $this->invoke_private_method( $page, 'get_script_handle_resolution_count' ), 'Script handles should be resolved once for a page and section context.' );
+ }
+
+ /**
+ * @testdox Should render the complete classic page once when schema validation fails.
+ */
+ public function test_invalid_schema_uses_complete_classic_fallback_once_without_changing_the_option(): void {
+ global $current_section, $current_tab, $wpdb;
+
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+ $this->setExpectedIncorrectUsage( 'WC_Settings_Page::output' );
+
+ update_option( 'woocommerce_settings_ui_flag_test', '02' );
+ $stored_before = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
+ 'woocommerce_settings_ui_flag_test'
+ )
+ );
+
+ $current_section = '';
+ $current_tab = 'settings_ui_flag_test';
+ $page = $this->get_settings_ui_test_page_with_invalid_schema();
+ $context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+ try {
+ $classes = $page->add_settings_ui_body_class( 'existing-class' );
+ $output = $this->render_settings_view( $page );
+ } finally {
+ remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+ }
+
+ $stored_after = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
+ 'woocommerce_settings_ui_flag_test'
+ )
+ );
+ $this->assertSame( 'existing-class', $classes, 'Classic body classes should remain unchanged.' );
+ $this->assertStringContainsString( 'name="woocommerce_settings_ui_flag_test"', $output );
+ $this->assertStringContainsString( 'class="woocommerce-save-button', $output );
+ $this->assertStringNotContainsString( 'data-wc-settings-ui="1"', $output );
+ $this->assertTrue( empty( $GLOBALS['hide_save_button'] ), 'The classic Save button should remain visible.' );
+ $this->assertNull( $context->get_schema(), 'An invalid schema must not be available for the shared settings asset.' );
+ $this->assertTrue( $context->has_schema_failed() );
+ $this->assertSame( 1, $this->invoke_private_method( $page, 'get_schema_resolution_count' ), 'Schema validation should run once per request context.' );
+ $this->assertSame( $stored_before, $stored_after, 'Classic fallback must preserve the raw stored option representation.' );
+ }
+
+ /**
+ * @testdox Should fall back to classic settings when a declared script handle is not registered.
+ */
+ public function test_unregistered_script_handle_uses_classic_fallback_with_precise_reason(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+ $this->setExpectedIncorrectUsage( 'WC_Settings_Page::output' );
+
+ global $current_section;
+ $current_section = '';
+ $page = $this->get_settings_ui_test_page_with_script_handles( array( 'settings-ui-missing-handle' ) );
+ $context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+ $this->assertFalse( $context->has_script_handles_failed(), 'A valid handle declaration should pass resolution.' );
+ $this->assertNotNull( $context->get_schema(), 'Schema access should not require script registration.' );
+ $this->assertTrue( $context->has_script_handle_loading_failed(), 'The strict loading check should reject an unregistered handle.' );
+ $this->assertStringContainsString( 'settings-ui-missing-handle', $context->get_script_handles_failure_reason() );
+ $this->assertStringContainsString( 'not registered', $context->get_script_handles_failure_reason() );
+
+ try {
+ ob_start();
+ $page->output();
+ $output = ob_get_clean();
+ } finally {
+ remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+ }
+
+ $this->assertStringContainsString( 'name="woocommerce_settings_ui_flag_test"', $output );
+ $this->assertStringNotContainsString( 'data-wc-settings-ui="1"', $output );
+ $this->assertTrue( empty( $GLOBALS['hide_save_button'] ), 'The classic Save button should remain visible.' );
+ }
+
+ /**
+ * @testdox Should reject a declared extension script handle that is not a string.
+ */
+ public function test_non_string_script_handle_is_rejected_before_schema_emission(): void {
+ $page = $this->get_settings_ui_test_page_with_script_handles( array( 42 ) );
+ $context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+ $this->assertTrue( $context->has_script_handles_failed() );
+ $this->assertNull( $context->get_schema() );
+ $this->assertStringContainsString( 'must be non-empty strings', $context->get_script_handles_failure_reason() );
+ $this->assertFalse( $context->has_schema_failed(), 'A script handle failure should not be reported as a schema failure.' );
+ $this->assertSame( 0, $page->get_schema_resolution_count(), 'The fail-closed schema path must not resolve the schema after a script handle failure.' );
+ }
+
+ /**
+ * @testdox Should stay fail-closed when has_schema_failed() is called before get_schema().
+ */
+ public function test_has_schema_failed_called_before_get_schema_stays_fail_closed(): void {
+ $page = $this->get_settings_ui_test_page_with_script_handles( array( 42 ) );
+ $context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+ $this->assertFalse( $context->has_schema_failed(), 'A script handle failure should not be reported as a schema failure, even when checked first.' );
+ $this->assertSame( 0, $page->get_schema_resolution_count(), 'Checking has_schema_failed() first must not resolve the schema after a script handle failure.' );
+ $this->assertNull( $context->get_schema() );
+ }
+
+ /**
+ * @testdox Should allow extensions to register declared script handles after WooCommerce collects dependencies.
+ */
+ public function test_script_handle_registered_at_later_hook_priority_is_enqueued_before_mount(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+
+ global $current_section;
+ $current_section = '';
+ $page = $this->get_settings_ui_test_page_with_script_handles(
+ array( ' settings-ui-late-registered-handle ', 'settings-ui-late-registered-handle' )
+ );
+ $this->set_current_settings_page_request( $page );
+ $context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+ // WooCommerce collects dependencies at priority 15; extensions may register them later in the same hook.
+ $dependencies = $this->invoke_private_method( new WCAdminAssets(), 'get_settings_ui_script_dependencies' );
+ $this->assertSame( array( 'wc-settings-ui', 'settings-ui-late-registered-handle' ), $dependencies );
+ $this->assertSame( array( 'settings-ui-late-registered-handle' ), $context->get_script_handles() );
+ $this->assertFalse( $context->has_script_handles_failed(), 'Dependency collection should only resolve handle declarations.' );
+ wp_register_script( 'settings-ui-late-registered-handle', false, array(), '1.0.0', true );
+
+ ob_start();
+ $page->output();
+ $output = ob_get_clean();
+
+ $this->assertTrue( wp_script_is( 'settings-ui-late-registered-handle', 'enqueued' ) );
+ $this->assertStringContainsString( 'data-wc-settings-ui="1"', $output );
+ $this->assertFalse( $context->has_script_handle_loading_failed() );
+ }
+
+ /**
+ * @testdox Should preserve the classic Save button and report a mount preparation exception.
+ */
+ public function test_mount_preparation_exception_preserves_classic_save_button_and_is_reported(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+
+ $caught = array();
+ $listener = static function ( $exception ) use ( &$caught ): void {
+ $caught[] = $exception;
+ };
+ add_action( 'woocommerce_caught_exception', $listener );
+
+ global $current_section, $current_tab;
+ $current_section = '';
+ $current_tab = 'settings_ui_flag_test';
+ $page = $this->get_settings_ui_test_page_with_script_handles( array(), true );
+
+ try {
+ $classes = $page->add_settings_ui_body_class( 'existing-class' );
+ $output = $this->render_settings_view( $page );
+ } finally {
+ remove_action( 'woocommerce_caught_exception', $listener );
+ }
+
+ $this->assertSame( 'existing-class', $classes, 'Classic body classes should remain unchanged.' );
+ $this->assertStringContainsString( 'name="woocommerce_settings_ui_flag_test"', $output );
+ $this->assertStringContainsString( 'class="woocommerce-save-button', $output );
+ $this->assertStringNotContainsString( 'data-wc-settings-ui="1"', $output );
+ $this->assertArrayNotHasKey( 'hide_save_button', $GLOBALS );
+ $this->assertCount( 1, $caught );
+ $this->assertSame( 'Unable to resolve the Settings UI page id.', $caught[0]->getMessage() );
+ $this->assertSame( 1, $this->invoke_private_method( $page, 'get_page_id_resolution_count' ), 'The failing extension method should run once per request.' );
+ $this->assertLogged( 'error', 'Settings UI rendering failed', array( 'source' => 'settings-ui' ) );
+ }
+
+ /**
+ * @testdox Should contain a schema failure after page id resolution has failed.
+ */
+ public function test_schema_failure_does_not_rethrow_a_cached_page_id_failure(): void {
+ $page = $this->get_settings_ui_test_page_with_script_handles( array(), true );
+ $context = SettingsUIRequestContext::for_settings_page( $page, '' );
+ $filter = static function ( array $settings ): array {
+ unset( $settings );
+ throw new \RuntimeException( 'Unable to build the Settings UI schema.' );
+ };
+
+ try {
+ $context->get_page_id();
+ $this->fail( 'Page id resolution should fail.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertSame( 'Unable to resolve the Settings UI page id.', $e->getMessage() );
+ }
+
+ add_filter( 'woocommerce_get_settings_settings_ui_flag_test', $filter );
+
+ try {
+ $this->assertNull( $context->get_schema() );
+ $this->assertTrue( $context->has_schema_failed() );
+ $this->assertStringContainsString( 'Unable to build the Settings UI schema.', $context->get_schema_failure_reason() );
+ } finally {
+ remove_filter( 'woocommerce_get_settings_settings_ui_flag_test', $filter );
+ }
+ }
+
+ /**
+ * @testdox Should use classic settings only for a request carrying the namespaced override.
+ */
+ public function test_classic_request_override_preserves_routing_without_changing_the_feature_flag(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+
+ global $current_section, $current_tab;
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only rendering override under test.
+ $current_section = 'test_gateway';
+ $current_tab = 'checkout';
+ $_GET['page'] = 'wc-settings';
+ $_GET['tab'] = 'checkout';
+ $_GET['section'] = 'test_gateway';
+ $_GET['wc_settings_ui'] = 'classic';
+ $expected_query = $_GET;
+ $page = $this->get_settings_ui_test_page_for_drill_down();
+
+ $classes = $page->add_settings_ui_body_class( 'existing-class' );
+ ob_start();
+ $page->output();
+ $classic_output = ob_get_clean();
+
+ $this->assertSame( $expected_query, $_GET, 'The override must not alter page or section routing.' );
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+ $this->assertSame( 'existing-class', $classes );
+ $this->assertStringContainsString( 'name="woocommerce_settings_ui_drill_down_test"', $classic_output );
+ $this->assertStringNotContainsString( 'data-wc-settings-ui="1"', $classic_output );
+ $this->assertTrue( empty( $GLOBALS['hide_save_button'] ), 'The classic Save button should remain visible.' );
+ $this->assertTrue( Features::is_enabled( 'settings-ui' ), 'The request override must not persistently disable the feature.' );
+
+ unset( $_GET['wc_settings_ui'] );
+ SettingsUIRequestContext::reset();
+ unset( $GLOBALS['hide_save_button'] );
+
+ ob_start();
+ $page->output();
+ $settings_ui_output = ob_get_clean();
+
+ $this->assertStringContainsString( 'data-wc-settings-ui="1"', $settings_ui_output );
+ $this->assertTrue( Features::is_enabled( 'settings-ui' ) );
}
/**
@@ -377,6 +631,67 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
$this->assertArrayNotHasKey( 'settingsUI', $settings );
}
+ /**
+ * @testdox Should keep shared settings unchanged when adapter page id resolution fails.
+ */
+ public function test_shared_settings_are_unchanged_when_page_id_resolution_fails(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ $this->set_current_settings_page_request( $this->get_settings_ui_test_page_with_script_handles( array(), true ) );
+ $original_settings = array( 'existing' => 'value' );
+
+ $settings = $this->invoke_private_method( new Settings(), 'add_settings_ui_schema', array( $original_settings ) );
+
+ $this->assertSame( $original_settings, $settings, 'A failing third-party adapter must not escape or change shared settings.' );
+ }
+
+ /**
+ * @testdox Should serialize numeric group ids as JSON object keys.
+ *
+ * @dataProvider numeric_group_ids
+ *
+ * @param string $group_id Numeric group id.
+ */
+ public function test_shared_settings_serialize_numeric_group_ids_as_json_object_keys( string $group_id ): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ $page = $this->get_settings_ui_test_page();
+ $this->set_current_settings_page_request( $page );
+
+ $settings_filter = static function () use ( $group_id ): array {
+ return array(
+ array(
+ 'id' => $group_id,
+ 'type' => 'title',
+ 'title' => 'Numeric group',
+ ),
+ );
+ };
+ add_filter( 'woocommerce_get_settings_settings_ui_flag_test', $settings_filter );
+
+ try {
+ $settings = $this->invoke_private_method( new Settings(), 'add_settings_ui_schema', array( array() ) );
+ } finally {
+ remove_filter( 'woocommerce_get_settings_settings_ui_flag_test', $settings_filter );
+ }
+
+ $schema = $settings['settingsUI']['settings_ui_flag_test']['default'];
+ $json = wp_json_encode( $schema );
+
+ $this->assertInstanceOf( \stdClass::class, $schema['groups'], 'The transport boundary should expose groups as an object.' );
+ $this->assertStringContainsString( '"groups":{"' . $group_id . '":{', $json, 'The encoded schema should keep the numeric group id as an object key.' );
+ }
+
+ /**
+ * Numeric group ids converted by PHP.
+ *
+ * @return array<string, array{string}>
+ */
+ public static function numeric_group_ids(): array {
+ return array(
+ 'zero' => array( '0' ),
+ 'positive' => array( '123' ),
+ );
+ }
+
/**
* It does not add settings UI script dependencies when the feature flag is disabled.
*/
@@ -959,25 +1274,113 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
}
/**
- * Get the script handle resolution count for a counting test page.
+ * Build a settings page whose Settings UI schema contains an unsupported field type.
*
- * @param \WC_Settings_Page $page Settings page.
- * @return int
+ * @return \WC_Settings_Page
*/
- private function get_script_handle_resolution_count( \WC_Settings_Page $page ): int {
- $method = new \ReflectionMethod( $page, 'get_script_handle_resolution_count' );
- $method->setAccessible( true );
+ private function get_settings_ui_test_page_with_invalid_schema(): \WC_Settings_Page {
+ return new class() extends \WC_Settings_Page {
+ /**
+ * Schema resolution count.
+ *
+ * @var int
+ */
+ private int $schema_resolution_count = 0;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'settings_ui_flag_test';
+ $this->label = 'Settings UI flag test';
+ }
+
+ /**
+ * Get the settings UI page adapter.
+ *
+ * @return \Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page(): ?\Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface {
+ return new class( $this ) extends \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter {
+ /**
+ * Build an invalid schema.
+ *
+ * @param string $section_id Section id.
+ * @return array
+ */
+ public function get_schema( string $section_id ): array {
+ $schema = parent::get_schema( $section_id );
+ $this->settings_page->increment_schema_resolution_count();
+ $schema['groups']['default']['fields'][0]['type'] = '';
- return (int) $method->invoke( $page );
+ return $schema;
+ }
+ };
+ }
+
+ /**
+ * Increment the schema resolution count.
+ */
+ public function increment_schema_resolution_count(): void {
+ ++$this->schema_resolution_count;
+ }
+
+ /**
+ * Get the schema resolution count.
+ *
+ * @return int
+ */
+ public function get_schema_resolution_count(): int {
+ return $this->schema_resolution_count;
+ }
+
+ /**
+ * Get settings for the default section.
+ *
+ * @return array
+ */
+ protected function get_settings_for_default_section() {
+ return array(
+ array(
+ 'id' => 'woocommerce_settings_ui_flag_test',
+ 'type' => 'text',
+ 'title' => 'Settings UI flag test',
+ ),
+ );
+ }
+ };
}
/**
- * Build a settings page with counting script handles.
+ * Build a settings page declaring extension script handles.
*
+ * @param array $script_handles Script handles.
+ * @param bool $fail_page_id Whether the adapter should fail to provide its page id.
* @return \WC_Settings_Page
*/
- private function get_settings_ui_test_page_with_counting_script_handles(): \WC_Settings_Page {
- return new class() extends \WC_Settings_Page {
+ private function get_settings_ui_test_page_with_script_handles( array $script_handles, bool $fail_page_id = false ): \WC_Settings_Page {
+ return new class( $script_handles, $fail_page_id ) extends \WC_Settings_Page {
+ /**
+ * Extension script handles.
+ *
+ * @var array
+ */
+ private array $script_handles;
+
+ /**
+ * Whether the adapter should fail to provide its page id.
+ *
+ * @var bool
+ */
+ private bool $fail_page_id;
+
+ /**
+ * Page id resolution count.
+ *
+ * @var int
+ */
+ private int $page_id_resolution_count = 0;
+
/**
* Script handle resolution count.
*
@@ -985,12 +1388,24 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
*/
private int $script_handle_resolution_count = 0;
+ /**
+ * Schema resolution count.
+ *
+ * @var int
+ */
+ private int $schema_resolution_count = 0;
+
/**
* Constructor.
+ *
+ * @param array $script_handles Script handles.
+ * @param bool $fail_page_id Whether the adapter should fail to provide its page id.
*/
- public function __construct() {
- $this->id = 'settings_ui_flag_test';
- $this->label = 'Settings UI flag test';
+ public function __construct( array $script_handles, bool $fail_page_id ) {
+ $this->id = 'settings_ui_flag_test';
+ $this->label = 'Settings UI flag test';
+ $this->script_handles = $script_handles;
+ $this->fail_page_id = $fail_page_id;
}
/**
@@ -999,7 +1414,49 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
* @return \Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface|null
*/
public function get_settings_ui_page(): ?\Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface {
- return new class( $this ) extends \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter {
+ return new class( $this, $this->script_handles, $this->fail_page_id ) extends \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter {
+ /**
+ * Extension script handles.
+ *
+ * @var array
+ */
+ private array $script_handles;
+
+ /**
+ * Whether the adapter should fail to provide its page id.
+ *
+ * @var bool
+ */
+ private bool $fail_page_id;
+
+ /**
+ * Constructor.
+ *
+ * @param \WC_Settings_Page $settings_page Settings page.
+ * @param array $script_handles Script handles.
+ * @param bool $fail_page_id Whether the adapter should fail to provide its page id.
+ */
+ public function __construct( \WC_Settings_Page $settings_page, array $script_handles, bool $fail_page_id ) {
+ parent::__construct( $settings_page );
+ $this->script_handles = $script_handles;
+ $this->fail_page_id = $fail_page_id;
+ }
+
+ /**
+ * Get the page id.
+ *
+ * @return string
+ */
+ public function get_page_id(): string {
+ $this->settings_page->increment_page_id_resolution_count();
+
+ if ( $this->fail_page_id ) {
+ throw new \RuntimeException( 'Unable to resolve the Settings UI page id.' );
+ }
+
+ return parent::get_page_id();
+ }
+
/**
* Get script handles.
*
@@ -1007,12 +1464,33 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
* @return array
*/
public function get_script_handles( string $section_id ): array {
+ unset( $section_id );
$this->settings_page->increment_script_handle_resolution_count();
- return array( 'settings-ui-counting-handle' );
+
+ return $this->script_handles;
+ }
+
+ /**
+ * Get the schema.
+ *
+ * @param string $section Section id.
+ * @return array
+ */
+ public function get_schema( string $section ): array {
+ $this->settings_page->increment_schema_resolution_count();
+
+ return parent::get_schema( $section );
}
};
}
+ /**
+ * Increment the page id resolution count.
+ */
+ public function increment_page_id_resolution_count(): void {
+ ++$this->page_id_resolution_count;
+ }
+
/**
* Increment the script handle resolution count.
*/
@@ -1020,6 +1498,22 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
++$this->script_handle_resolution_count;
}
+ /**
+ * Increment the schema resolution count.
+ */
+ public function increment_schema_resolution_count(): void {
+ ++$this->schema_resolution_count;
+ }
+
+ /**
+ * Get the page id resolution count.
+ *
+ * @return int
+ */
+ public function get_page_id_resolution_count(): int {
+ return $this->page_id_resolution_count;
+ }
+
/**
* Get the script handle resolution count.
*
@@ -1029,6 +1523,15 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
return $this->script_handle_resolution_count;
}
+ /**
+ * Get the schema resolution count.
+ *
+ * @return int
+ */
+ public function get_schema_resolution_count(): int {
+ return $this->schema_resolution_count;
+ }
+
/**
* Get settings for the default section.
*
@@ -1155,4 +1658,30 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
return $method->invokeArgs( $target, $arguments );
}
+
+ /**
+ * Render the complete classic settings view for a test page.
+ *
+ * @param \WC_Settings_Page $page Settings page.
+ * @return string
+ */
+ private function render_settings_view( \WC_Settings_Page $page ): string {
+ global $current_tab;
+
+ $tabs = array( $current_tab => $page->get_label() );
+ $action = array( $page, 'output' );
+ add_action( 'woocommerce_settings_' . $current_tab, $action );
+ $buffer_level = ob_get_level();
+ ob_start();
+
+ try {
+ include WC_ABSPATH . 'includes/admin/views/html-admin-settings.php';
+ return (string) ob_get_clean();
+ } finally {
+ while ( ob_get_level() > $buffer_level ) {
+ ob_end_clean();
+ }
+ remove_action( 'woocommerce_settings_' . $current_tab, $action );
+ }
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIPages/ProductsSettingsPageAdapterTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIPages/ProductsSettingsPageAdapterTest.php
index 1def530fac3..02148e88087 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIPages/ProductsSettingsPageAdapterTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIPages/ProductsSettingsPageAdapterTest.php
@@ -9,6 +9,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\Admin\Settings\SettingsUIPages;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUISchema;
use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIPages\ProductsSettingsPageAdapter;
use WC_Unit_Test_Case;
@@ -18,7 +19,7 @@ use WC_Unit_Test_Case;
class ProductsSettingsPageAdapterTest extends WC_Unit_Test_Case {
/**
- * It adds page options to the shop page selector.
+ * @testdox It adds page options to the shop page selector.
*/
public function test_get_schema_adds_shop_page_options(): void {
$page_id = self::factory()->post->create(
@@ -77,4 +78,30 @@ class ProductsSettingsPageAdapterTest extends WC_Unit_Test_Case {
$field['options']
);
}
+
+ /**
+ * @testdox It builds and validates every real Products settings section.
+ */
+ public function test_get_schema_builds_valid_products_settings_sections(): void {
+ if ( ! class_exists( 'WC_Settings_Products', false ) ) {
+ require_once WC_ABSPATH . 'includes/admin/settings/class-wc-settings-products.php';
+ }
+
+ $settings_page = new \WC_Settings_Products();
+ $adapter = $settings_page->get_settings_ui_page();
+ $sections = array( '', 'inventory', 'downloadable', 'advanced' );
+
+ $this->assertInstanceOf( ProductsSettingsPageAdapter::class, $adapter, 'The Products settings page should use the Settings UI adapter.' );
+ $this->assertSame( $sections, array_keys( $settings_page->get_sections() ), 'Every core Products section should be covered by this schema validation test.' );
+
+ foreach ( $sections as $section ) {
+ $schema = $adapter->get_schema( $section );
+ $expected_schema_section = '' === $section ? 'default' : $section;
+
+ SettingsUISchema::assert_valid_schema( $schema );
+ $this->assertSame( 'products', $schema['id'], 'The real schema should keep the Products page ID.' );
+ $this->assertSame( $expected_schema_section, $schema['section'], 'The real schema should keep the Products section ID.' );
+ $this->assertNotEmpty( $schema['groups'], 'Every real Products section should contain settings groups.' );
+ }
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
index 0084a19bfd1..3f3f0e93228 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
@@ -287,6 +287,322 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
$this->assertSame( 'Read-only <strong>information</strong>alert("x").', $field['description'] );
$this->assertSame( array( 'adapter' => 'none' ), $field['save'] );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It builds options for legacy page selectors that do not declare options.
+ */
+ public function test_from_legacy_settings_builds_page_options(): void {
+ $page_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Checkout',
+ )
+ );
+
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_page',
+ 'label' => 'Acme page',
+ 'type' => 'single_select_page',
+ 'value' => (string) $page_id,
+ ),
+ )
+ );
+
+ $field = $schema['groups']['default']['fields'][0];
+ $options = array_column( $field['options'], 'label', 'value' );
+
+ $this->assertSame( 'select', $field['type'], 'The legacy page selector should use the canonical select type.' );
+ $this->assertSame( (string) $page_id, $field['value'], 'The selected page ID should stay unchanged.' );
+ $this->assertSame( 'Checkout', $options[ (string) $page_id ], 'The created page should be available as an option.' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It builds country and state options for legacy country selectors that do not declare options.
+ */
+ public function test_from_legacy_settings_builds_country_and_state_options(): void {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_country',
+ 'label' => 'Acme country',
+ 'type' => 'single_select_country',
+ 'value' => 'US:CA',
+ ),
+ )
+ );
+
+ $field = $schema['groups']['default']['fields'][0];
+ $options = array_column( $field['options'], 'label', 'value' );
+ $country_label = WC()->countries->get_countries()['US'];
+ $state_label = WC()->countries->get_states( 'US' )['CA'];
+
+ $this->assertSame( 'select', $field['type'], 'The legacy country selector should use the canonical select type.' );
+ $this->assertSame( 'US:CA', $field['value'], 'The selected country and state value should stay unchanged.' );
+ $this->assertSame( $country_label . ' — ' . $state_label, $options['US:CA'], 'The state option should include its country label.' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It builds options for legacy country multiselects that do not declare options.
+ */
+ public function test_from_legacy_settings_builds_country_multiselect_options(): void {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_countries',
+ 'label' => 'Acme countries',
+ 'type' => 'multi_select_countries',
+ 'value' => array( 'US', 'MA' ),
+ ),
+ )
+ );
+
+ $field = $schema['groups']['default']['fields'][0];
+ $options = array_column( $field['options'], 'label', 'value' );
+
+ $this->assertSame( 'array', $field['type'], 'The legacy country multiselect should use the canonical array type.' );
+ $this->assertSame( array( 'US', 'MA' ), $field['value'], 'The selected country values should stay unchanged.' );
+ $this->assertSame( WC()->countries->get_countries()['US'], $options['US'], 'The United States should be available as an option.' );
+ $this->assertSame( WC()->countries->get_countries()['MA'], $options['MA'], 'Morocco should be available as an option.' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It sorts declared legacy country multiselect options by label.
+ */
+ public function test_from_legacy_settings_sorts_declared_country_multiselect_options(): void {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_countries',
+ 'label' => 'Acme countries',
+ 'type' => 'multi_select_countries',
+ 'options' => array(
+ 'US' => 'Zulu country',
+ 'MA' => 'Alpha country',
+ ),
+ ),
+ )
+ );
+
+ $field = $schema['groups']['default']['fields'][0];
+
+ $this->assertSame(
+ array(
+ array(
+ 'label' => 'Alpha country',
+ 'value' => 'MA',
+ ),
+ array(
+ 'label' => 'Zulu country',
+ 'value' => 'US',
+ ),
+ ),
+ $field['options'],
+ 'The canonical options should match the label order used by the classic renderer.'
+ );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It leaves generated country options empty when the countries controller is unavailable.
+ */
+ public function test_from_legacy_settings_handles_an_unavailable_countries_controller(): void {
+ $woocommerce = WC();
+ $original_countries = $woocommerce->countries;
+ $woocommerce->countries = null;
+
+ try {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_country',
+ 'type' => 'single_select_country',
+ ),
+ array(
+ 'id' => 'acme_countries',
+ 'type' => 'multi_select_countries',
+ ),
+ )
+ );
+ } finally {
+ $woocommerce->countries = $original_countries;
+ }
+
+ $fields = $schema['groups']['default']['fields'];
+ $this->assertArrayNotHasKey( 'options', $fields[0], 'The country selector should not fail when the countries controller is unavailable.' );
+ $this->assertArrayNotHasKey( 'options', $fields[1], 'The country multiselect should not fail when the countries controller is unavailable.' );
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * @testdox It accepts an ordinary legacy multiselect with no options.
+ */
+ public function test_from_legacy_settings_accepts_multiselect_without_options(): void {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_choices',
+ 'label' => 'Acme choices',
+ 'type' => 'multiselect',
+ 'value' => array(),
+ ),
+ )
+ );
+
+ SettingsUISchema::assert_valid_schema( $schema );
+
+ $field = $schema['groups']['default']['fields'][0];
+ $this->assertSame( 'array', $field['type'], 'The legacy multiselect should use the canonical array type.' );
+ $this->assertArrayNotHasKey( 'options', $field, 'An empty legacy option map should remain an empty choice set.' );
+ }
+
+ /**
+ * @testdox It rejects duplicate legacy group ids before either group can be overwritten.
+ *
+ * @dataProvider duplicate_legacy_group_ids
+ *
+ * @param string $group_id Duplicate group id.
+ */
+ public function test_from_legacy_settings_rejects_duplicate_group_ids( string $group_id ): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( sprintf( 'Group id "%s" is duplicated.', $group_id ) );
+
+ SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => $group_id,
+ 'type' => 'title',
+ 'title' => 'First',
+ ),
+ array(
+ 'id' => 'acme_enabled',
+ 'type' => 'checkbox',
+ 'title' => 'Enabled',
+ ),
+ array( 'type' => 'sectionend' ),
+ array(
+ 'id' => $group_id,
+ 'type' => 'title',
+ 'title' => 'Second',
+ ),
+ array(
+ 'id' => 'acme_label',
+ 'type' => 'text',
+ 'title' => 'Label',
+ ),
+ )
+ );
+ }
+
+ /**
+ * Duplicate legacy group id fixtures.
+ *
+ * @return array<string, array{string}>
+ */
+ public static function duplicate_legacy_group_ids(): array {
+ return array(
+ 'normal id' => array( 'main' ),
+ 'zero-string id' => array( '0' ),
+ );
+ }
+
+ /**
+ * @testdox It gives separate generated groups to separate runs of loose fields.
+ */
+ public function test_from_legacy_settings_preserves_separate_runs_of_loose_fields(): void {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_before',
+ 'type' => 'text',
+ 'title' => 'Before',
+ ),
+ array(
+ 'id' => 'main',
+ 'type' => 'title',
+ 'title' => 'Main',
+ ),
+ array(
+ 'id' => 'acme_main',
+ 'type' => 'text',
+ 'title' => 'Main field',
+ ),
+ array( 'type' => 'sectionend' ),
+ array(
+ 'id' => 'acme_after',
+ 'type' => 'text',
+ 'title' => 'After',
+ ),
+ )
+ );
+
+ $this->assertSame( array( 'default', 'main', 'default_1' ), array_keys( $schema['groups'] ) );
+ $this->assertSame( 'acme_before', $schema['groups']['default']['fields'][0]['id'] );
+ $this->assertSame( 'acme_main', $schema['groups']['main']['fields'][0]['id'] );
+ $this->assertSame( 'acme_after', $schema['groups']['default_1']['fields'][0]['id'] );
+ }
+
+ /**
+ * @testdox It reserves explicit group ids when it generates a group for loose fields.
+ */
+ public function test_from_legacy_settings_avoids_explicit_group_id_when_generating_loose_group(): void {
+ $schema = SettingsUISchema::from_legacy_settings(
+ 'acme',
+ '',
+ 'Acme',
+ array(
+ array(
+ 'id' => 'acme_loose',
+ 'type' => 'text',
+ 'title' => 'Loose field',
+ ),
+ array(
+ 'id' => 'default',
+ 'type' => 'title',
+ 'title' => 'Declared default',
+ ),
+ array(
+ 'id' => 'acme_declared',
+ 'type' => 'text',
+ 'title' => 'Declared field',
+ ),
+ )
+ );
+
+ $this->assertSame( array( 'default_1', 'default' ), array_keys( $schema['groups'] ) );
+ $this->assertSame( 'acme_loose', $schema['groups']['default_1']['fields'][0]['id'] );
+ $this->assertSame( 'acme_declared', $schema['groups']['default']['fields'][0]['id'] );
}
/**
@@ -710,6 +1026,321 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
$this->assertSame( $schema, SettingsUISchema::canonicalize_option_values( $schema ), 'Associative value arrays should pass through unreindexed for the provider to fix.' );
}
+ /**
+ * @testdox It accepts Settings UI transport values without interpreting the field type.
+ *
+ * @dataProvider settings_ui_values
+ *
+ * @param string $type Field type.
+ * @param mixed $value Field value.
+ */
+ public function test_assert_valid_schema_accepts_settings_ui_values_without_interpreting_field_type( string $type, $value ): void {
+ $field = array(
+ 'id' => 'acme_custom_field',
+ 'label' => 'Acme custom field',
+ 'type' => $type,
+ 'value' => $value,
+ 'save' => array( 'adapter' => 'form_post' ),
+ );
+
+ SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_field( $field ) );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * Settings UI transport value fixtures.
+ *
+ * @return array<string, array{string, mixed}>
+ */
+ public static function settings_ui_values(): array {
+ return array(
+ 'number string' => array( 'number', '02' ),
+ 'datetime-local string' => array( 'datetime-local', '2026-08-03T12:30' ),
+ 'extension string' => array( 'acme/custom', 'Acme' ),
+ 'extension integer' => array( 'acme/custom', 10 ),
+ 'extension float' => array( 'acme/custom', 10.5 ),
+ 'extension boolean' => array( 'acme/custom', true ),
+ 'extension string list' => array( 'acme/custom', array( 'one', 'two' ) ),
+ 'extension null' => array( 'acme/custom', null ),
+ );
+ }
+
+ /**
+ * @testdox It accepts choice fields with missing or empty option lists.
+ */
+ public function test_assert_valid_schema_accepts_choice_fields_without_options(): void {
+ $fields = array(
+ array(
+ 'id' => 'acme_select_without_options',
+ 'label' => 'Select without options',
+ 'type' => 'select',
+ 'value' => '',
+ 'save' => array( 'adapter' => 'form_post' ),
+ ),
+ array(
+ 'id' => 'acme_array_with_empty_options',
+ 'label' => 'Array with empty options',
+ 'type' => 'array',
+ 'value' => array(),
+ 'options' => array(),
+ 'save' => array( 'adapter' => 'form_post' ),
+ ),
+ );
+
+ SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_fields( $fields ) );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * @testdox It accepts scalar custom attributes without interpreting renderer semantics.
+ */
+ public function test_assert_valid_schema_accepts_scalar_custom_attributes_without_interpreting_renderer_semantics(): void {
+ $field = array(
+ 'id' => 'acme_custom_field',
+ 'label' => 'Acme custom field',
+ 'type' => 'acme/custom',
+ 'value' => '',
+ 'customAttributes' => array(
+ 'min' => 'extension-defined',
+ 'max' => 10,
+ 'step' => 'any',
+ 'data-enabled' => true,
+ ),
+ 'save' => array( 'adapter' => 'form_post' ),
+ );
+
+ SettingsUISchema::assert_valid_schema( $this->get_native_schema_with_field( $field ) );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * @testdox It rejects malformed schemas with a precise boundary reason.
+ *
+ * @dataProvider invalid_schemas
+ *
+ * @param array $schema Invalid schema.
+ * @param string $reason Expected exception message.
+ */
+ public function test_assert_valid_schema_rejects_malformed_schemas( array $schema, string $reason ): void {
+ $this->expectException( \InvalidArgumentException::class );
+ $this->expectExceptionMessage( $reason );
+
+ SettingsUISchema::assert_valid_schema( $schema );
+ }
+
+ /**
+ * Invalid schema fixtures.
+ *
+ * @return array<string, array{array, string}>
+ */
+ public static function invalid_schemas(): array {
+ $valid = self::get_valid_schema_for_validation();
+
+ $empty_type = $valid;
+ $empty_type['groups']['main']['fields'][0]['type'] = '';
+
+ $duplicate_id = $valid;
+ $duplicate_id['groups']['main']['fields'][] = $duplicate_id['groups']['main']['fields'][0];
+ $group_field_collision = $valid;
+ $group_field_collision['groups']['main']['fields'][0]['id'] = 'main';
+ $empty_schema_id = $valid;
+ $empty_schema_id['id'] = '';
+ $malformed_group = $valid;
+ $malformed_group['groups']['main']['fields'] = 'invalid';
+ $invalid_options = $valid;
+ $invalid_options['groups']['main']['fields'][0]['type'] = 'select';
+ $invalid_options['groups']['main']['fields'][0]['options'] = array( 'one' => 'One' );
+ $null_options = $valid;
+ $null_options['groups']['main']['fields'][0]['type'] = 'select';
+ $null_options['groups']['main']['fields'][0]['options'] = null;
+ $invalid_option = $valid;
+ $invalid_option['groups']['main']['fields'][0]['type'] = 'select';
+ $invalid_option['groups']['main']['fields'][0]['options'] = array(
+ array(
+ 'label' => 'One',
+ 'value' => 1,
+ ),
+ );
+ $invalid_component = $valid;
+ $invalid_component['groups']['main']['fields'][0]['component'] = '';
+ $invalid_field_save = $valid;
+ $invalid_field_save['groups']['main']['fields'][0]['save'] = array( 'adapter' => 'custom' );
+ $invalid_visibility = $valid;
+ $invalid_visibility['groups']['main']['fields'][0]['visibility'] = array( 'controller' => 'missing' );
+ $invalid_field_value = $valid;
+ $invalid_field_value['groups']['main']['fields'][0]['value'] = array( 'tier' => 1 );
+ $invalid_custom_attributes = $valid;
+ $invalid_custom_attributes['groups']['main']['fields'][0]['customAttributes'] = 'invalid';
+ $invalid_custom_attribute_value = $valid;
+ $invalid_custom_attribute_value['groups']['main']['fields'][0]['customAttributes'] = array( 'data-values' => array() );
+ $invalid_custom_attribute_float = $valid;
+ $invalid_custom_attribute_float['groups']['main']['fields'][0]['customAttributes'] = array( 'data-value' => INF );
+ $invalid_info = $valid;
+ $invalid_info['groups']['main']['fields'][0]['type'] = 'info';
+ $invalid_shell = $valid;
+ $invalid_shell['shell']['navigation'] = array(
+ array(
+ 'id' => 'general',
+ 'label' => 'General',
+ ),
+ );
+ $invalid_breadcrumb = $valid;
+ $invalid_breadcrumb['shell']['breadcrumbs'] = array( array( 'label' => 1 ) );
+ $invalid_badge = $valid;
+ $invalid_badge['shell']['badges'] = array(
+ array(
+ 'label' => 'Beta',
+ 'intent' => array( 'invalid' ),
+ ),
+ );
+ $invalid_action = $valid;
+ $invalid_action['groups']['main']['actions'] = array(
+ array(
+ 'id' => '',
+ 'label' => 'Docs',
+ 'href' => 'https://example.com',
+ ),
+ );
+ $invalid_page_save = $valid;
+ $invalid_page_save['save'] = array( 'adapter' => 'custom' );
+ $invalid_navigation_component = $valid;
+ $invalid_navigation_component['shell']['navigationComponent'] = '';
+ $invalid_group_map = $valid;
+ $invalid_group_map['groups']['other'] = $invalid_group_map['groups']['main'];
+ unset( $invalid_group_map['groups']['main'] );
+
+ return array(
+ 'empty field type' => array( $empty_type, 'Field "acme_field" type must be a non-empty string.' ),
+ 'duplicate field id' => array( $duplicate_id, 'Field id "acme_field" is duplicated.' ),
+ 'group and field collision' => array( $group_field_collision, 'Field id "main" collides with a group id.' ),
+ 'empty schema id' => array( $empty_schema_id, 'Schema id must be a non-empty string.' ),
+ 'malformed group fields' => array( $malformed_group, 'Group "main" fields must be a list.' ),
+ 'non-list choice options' => array( $invalid_options, 'Field "acme_field" options must be a list.' ),
+ 'null choice options' => array( $null_options, 'Field "acme_field" options must be a list.' ),
+ 'non-string option value' => array( $invalid_option, 'Field "acme_field" option 0 value must be a string.' ),
+ 'empty component name' => array( $invalid_component, 'Field "acme_field" component must be a non-empty string.' ),
+ 'unsupported field save' => array( $invalid_field_save, 'Field "acme_field" save adapter must be "form_post" or "none".' ),
+ 'missing visibility control' => array( $invalid_visibility, 'Field "acme_field" visibility controller "missing" does not reference a field.' ),
+ 'invalid field value' => array( $invalid_field_value, 'Field "acme_field" value is not a valid Settings UI value.' ),
+ 'invalid custom attributes' => array( $invalid_custom_attributes, 'Field "acme_field" customAttributes must be a map.' ),
+ 'invalid custom value' => array( $invalid_custom_attribute_value, 'Field "acme_field" custom attribute "data-values" has an invalid value.' ),
+ 'non-finite custom value' => array( $invalid_custom_attribute_float, 'Field "acme_field" custom attribute "data-value" has an invalid value.' ),
+ 'saving info field' => array( $invalid_info, 'Field "acme_field" of type "info" must use the "none" save adapter.' ),
+ 'malformed shell navigation' => array( $invalid_shell, 'Shell navigation item 0 href must be a string.' ),
+ 'malformed breadcrumb' => array( $invalid_breadcrumb, 'Shell breadcrumb 0 label must be a string.' ),
+ 'invalid badge intent' => array( $invalid_badge, 'Shell badge 0 intent must be a string.' ),
+ 'empty group action id' => array( $invalid_action, 'Group "main" action 0 id must be a non-empty string.' ),
+ 'custom save without handler' => array( $invalid_page_save, 'Schema custom save strategy must define a non-empty handler.' ),
+ 'empty navigation component' => array( $invalid_navigation_component, 'Shell navigationComponent must be a non-empty string.' ),
+ 'group map id mismatch' => array( $invalid_group_map, 'Group map key "other" must match group id "main".' ),
+ );
+ }
+
+ /**
+ * @testdox It accepts valid optional shell, field, visibility, action, and save metadata.
+ */
+ public function test_assert_valid_schema_accepts_optional_metadata(): void {
+ $schema = self::get_valid_schema_for_validation();
+ $schema['save'] = array(
+ 'adapter' => 'custom',
+ 'handler' => 'acme/save',
+ );
+ $schema['shell'] = array(
+ 'header' => 'visible',
+ 'title' => 'Acme settings',
+ 'subtitle' => 'Configure Acme.',
+ 'breadcrumbs' => array(
+ array(
+ 'label' => 'Settings',
+ 'href' => 'https://example.com/settings',
+ ),
+ ),
+ 'badges' => array(
+ array(
+ 'label' => 'Beta',
+ 'intent' => 'extension-defined-intent',
+ ),
+ ),
+ 'navigation' => array(
+ array(
+ 'id' => 'general',
+ 'label' => 'General',
+ 'href' => 'https://example.com/general',
+ 'active' => true,
+ ),
+ ),
+ 'sectionNavigation' => array(),
+ 'navigationComponent' => 'acme/navigation',
+ );
+ $schema['groups']['main']['actions'] = array(
+ array(
+ 'id' => 'docs',
+ 'label' => 'Documentation',
+ 'href' => 'https://example.com/docs',
+ 'variant' => 'link',
+ 'target' => '_blank',
+ 'rel' => 'noopener',
+ ),
+ );
+ $schema['groups']['main']['fields'][0]['component'] = 'acme/text';
+ $schema['groups']['main']['fields'][0]['visibility'] = array(
+ 'controller' => 'acme_enabled',
+ 'value' => array( true, false ),
+ );
+ $schema['groups']['main']['fields'][] = array(
+ 'id' => 'acme_enabled',
+ 'label' => 'Enabled',
+ 'type' => 'checkbox',
+ 'value' => true,
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_enabled',
+ ),
+ );
+
+ SettingsUISchema::assert_valid_schema( $schema );
+ $this->addToAssertionCount( 1 );
+ }
+
+ /**
+ * Build a valid schema for validation tests.
+ *
+ * @return array
+ */
+ private static function get_valid_schema_for_validation(): array {
+ return array(
+ 'id' => 'acme',
+ 'title' => 'Acme',
+ 'section' => 'general',
+ 'save' => array( 'adapter' => 'form_post' ),
+ 'shell' => array(
+ 'header' => 'hidden',
+ 'title' => 'Acme',
+ ),
+ 'groups' => array(
+ 'main' => array(
+ 'id' => 'main',
+ 'title' => 'Main',
+ 'description' => 'Main settings.',
+ 'actions' => array(),
+ 'fields' => array(
+ array(
+ 'id' => 'acme_field',
+ 'label' => 'Acme field',
+ 'type' => 'text',
+ 'description' => 'A text field.',
+ 'value' => 'Acme',
+ 'save' => array(
+ 'adapter' => 'form_post',
+ 'name' => 'acme_field',
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
/**
* Build a minimal native schema with one field.
*