diff --git a/packages/react/src/checkbox/root/CheckboxRoot.tsx b/packages/react/src/checkbox/root/CheckboxRoot.tsx
index 47419321965..7b20f9ea323 100644
--- a/packages/react/src/checkbox/root/CheckboxRoot.tsx
+++ b/packages/react/src/checkbox/root/CheckboxRoot.tsx
@@ -181,7 +181,7 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot(
const registerFieldInput = validation.registerInput;
const registeredInputValue = groupContext ? value : undefined;
const registerInput = React.useCallback(
- (element: HTMLInputElement | null) =>
+ (element: HTMLInputElement) =>
registerFieldInput(element, { controlRef, value: registeredInputValue }),
[registerFieldInput, registeredInputValue],
);
diff --git a/packages/react/src/field/control/FieldControl.test.tsx b/packages/react/src/field/control/FieldControl.test.tsx
index 02dc737d371..2a4f3d6f603 100644
--- a/packages/react/src/field/control/FieldControl.test.tsx
+++ b/packages/react/src/field/control/FieldControl.test.tsx
@@ -1,6 +1,7 @@
import { expect, vi } from 'vitest';
import { createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
import { Field } from '@base-ui/react/field';
+import { Form } from '@base-ui/react/form';
import { describeConformance, isJSDOM } from '#test-utils';
describe('', () => {
@@ -56,6 +57,31 @@ describe('', () => {
expect(validate.mock.lastCall?.[0]).toBe('a');
});
+ it('does not clear errors or validate when change is prevented', async () => {
+ const validate = vi.fn();
+ const handleValueChange = vi.fn();
+
+ await render(
+
,
+ );
+
+ const control = screen.getByRole('textbox');
+ control.addEventListener('input', (event) => event.preventDefault(), {
+ capture: true,
+ once: true,
+ });
+ fireEvent.input(control, { cancelable: true, target: { value: 'a' } });
+
+ expect(handleValueChange).toHaveBeenCalledTimes(1);
+ expect(validate).not.toHaveBeenCalled();
+ expect(screen.getByText('Server error')).toBeInTheDocument();
+ });
+
it('shows a required error when a prefilled value is cleared', async () => {
await render(
diff --git a/packages/react/src/field/description/FieldDescription.test.tsx b/packages/react/src/field/description/FieldDescription.test.tsx
index fc6df38af5f..1806d90a979 100644
--- a/packages/react/src/field/description/FieldDescription.test.tsx
+++ b/packages/react/src/field/description/FieldDescription.test.tsx
@@ -40,6 +40,17 @@ describe('', () => {
);
});
+ it('does not register an empty description id', () => {
+ render(
+
+
+ Message
+ ,
+ );
+
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby', 'external-description');
+ });
+
it('reflects the disabled state from Field.Item', async () => {
await render(
diff --git a/packages/react/src/field/error/FieldError.test.tsx b/packages/react/src/field/error/FieldError.test.tsx
index 30a3d75dfad..4d9dcc75338 100644
--- a/packages/react/src/field/error/FieldError.test.tsx
+++ b/packages/react/src/field/error/FieldError.test.tsx
@@ -211,6 +211,40 @@ describe('', () => {
expect(screen.getByTestId('default-error')).toHaveTextContent('Username is reserved');
});
+ it('renders client validation error arrays as a list', async () => {
+ await render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('submit'));
+
+ const list = screen.getByTestId('default-error').querySelector('ul');
+ expect(list).not.toBe(null);
+ expect(list?.querySelectorAll('li')).toHaveLength(2);
+ expect(screen.getByText('First error')).not.toBe(null);
+ expect(screen.getByText('Second error')).not.toBe(null);
+ });
+
+ it('does not register an empty error id', async () => {
+ await render(
+
+
+ Message
+ ,
+ );
+
+ expect(screen.getByRole('textbox')).toHaveAttribute(
+ 'aria-describedby',
+ 'external-description',
+ );
+ });
+
it('ignores empty Form error arrays', async () => {
await render(
,
+ );
+
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Base UI: expected a ,
+ );
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(' expected a element'),
+ );
+ } finally {
+ errorSpy.mockRestore();
+ }
+ });
+
+ it('reports non-native label mismatches without the owner-stack API', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ try {
+ await render(
+
+ Label
+ ,
+ );
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(' expected a non- element'),
+ );
+ } finally {
+ errorSpy.mockRestore();
+ }
+ });
+});
diff --git a/packages/react/src/field/root/FieldRoot.test.tsx b/packages/react/src/field/root/FieldRoot.test.tsx
index ca8e4a2d40d..8270f5e27d9 100644
--- a/packages/react/src/field/root/FieldRoot.test.tsx
+++ b/packages/react/src/field/root/FieldRoot.test.tsx
@@ -1651,6 +1651,18 @@ describe('', () => {
expect(control).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByTestId('error')).not.toBe(null);
});
+
+ it('does not update controlled dirty state from user input', async () => {
+ await render(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } });
+
+ expect(screen.getByTestId('root')).not.toHaveAttribute('data-dirty');
+ });
});
describe('prop: touched', () => {
@@ -1668,6 +1680,19 @@ describe('', () => {
expect(screen.getByTestId(part)).toHaveAttribute('data-touched');
});
});
+
+ it('does not update controlled touched state on blur', async () => {
+ await render(
+
+
+ ,
+ );
+
+ fireEvent.focus(screen.getByRole('textbox'));
+ fireEvent.blur(screen.getByRole('textbox'));
+
+ expect(screen.getByTestId('root')).not.toHaveAttribute('data-touched');
+ });
});
describe('prop: actionsRef', () => {
@@ -1696,6 +1721,28 @@ describe('', () => {
expect(screen.queryByTestId('error')).not.toBe(null);
});
+ it('validates a logical field without a mounted control', async () => {
+ function App() {
+ const actionsRef = React.useRef(null);
+ return (
+
+ 'Logical field error'}>
+
+
+
+
+ );
+ }
+
+ const { user } = await render();
+
+ await user.click(screen.getByRole('button', { name: 'validate' }));
+
+ expect(screen.getByText('Logical field error')).toBeVisible();
+ });
+
it('validates the current control value when the `validate` method is called', async () => {
const validate = vi.fn((value) => (value === 'valid' ? null : 'error'));
diff --git a/packages/react/src/field/root/useFieldValidation.ts b/packages/react/src/field/root/useFieldValidation.ts
index 8038d9dfd8a..90e5efe31b8 100644
--- a/packages/react/src/field/root/useFieldValidation.ts
+++ b/packages/react/src/field/root/useFieldValidation.ts
@@ -99,10 +99,7 @@ export function useFieldValidation(
// projection can use the same live controls. This also ensures a `required` checkbox can't be
// satisfied by another input in the group, matching native per-checkbox behavior.
const registerInput = React.useCallback(
- (element: HTMLInputElement | null, registration: RegisteredInput) => {
- if (!element) {
- return undefined;
- }
+ (element: HTMLInputElement, registration: RegisteredInput) => {
registeredInputs.set(element, registration);
return () => {
registeredInputs.delete(element);
@@ -370,10 +367,7 @@ export interface UseFieldValidationReturnValue {
getValidationProps: (disabled: boolean, props?: HTMLProps) => HTMLProps;
inputRef: React.RefObject;
registeredInputs: RegisteredInputs;
- registerInput: (
- element: HTMLInputElement | null,
- registration: RegisteredInput,
- ) => void | (() => void);
+ registerInput: (element: HTMLInputElement, registration: RegisteredInput) => void | (() => void);
getInputControl: () => HTMLElement | null;
commit: (value: unknown) => Promise;
change: (value: unknown) => void;
diff --git a/packages/react/src/fieldset/legend/FieldsetLegend.test.tsx b/packages/react/src/fieldset/legend/FieldsetLegend.test.tsx
index 6938e22519e..5b6db2a6756 100644
--- a/packages/react/src/fieldset/legend/FieldsetLegend.test.tsx
+++ b/packages/react/src/fieldset/legend/FieldsetLegend.test.tsx
@@ -1,5 +1,6 @@
import { expect, vi } from 'vitest';
-import { createRenderer, screen, waitFor } from '@mui/internal-test-utils';
+import * as React from 'react';
+import { createRenderer, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
import { Fieldset } from '@base-ui/react/fieldset';
import { describeConformance, isJSDOM } from '#test-utils';
@@ -36,6 +37,35 @@ describe('', () => {
expect(screen.getByRole('group')).toHaveAttribute('aria-labelledby', 'legend-id');
});
+ it('updates and clears the legend association', async () => {
+ function App() {
+ const [legendId, setLegendId] = React.useState('legend-a');
+ const [showLegend, setShowLegend] = React.useState(true);
+
+ return (
+
+
+ {showLegend ? Legend : null}
+
+
+
+
+ );
+ }
+
+ render();
+
+ expect(screen.getByRole('group')).toHaveAttribute('aria-labelledby', 'legend-a');
+ fireEvent.click(screen.getByRole('button', { name: 'Change id' }));
+ expect(screen.getByRole('group')).toHaveAttribute('aria-labelledby', 'legend-b');
+ fireEvent.click(screen.getByRole('button', { name: 'Remove legend' }));
+ expect(screen.getByRole('group')).not.toHaveAttribute('aria-labelledby');
+ });
+
it('throws a descriptive error when rendered outside ', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
diff --git a/packages/react/src/fieldset/legend/FieldsetLegend.tsx b/packages/react/src/fieldset/legend/FieldsetLegend.tsx
index e35beadc355..1b67a13b1e9 100644
--- a/packages/react/src/fieldset/legend/FieldsetLegend.tsx
+++ b/packages/react/src/fieldset/legend/FieldsetLegend.tsx
@@ -1,10 +1,9 @@
'use client';
import * as React from 'react';
-import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
-import { useBaseUiId } from '../../internals/useBaseUiId';
import { useRenderElement } from '../../internals/useRenderElement';
import { useFieldsetRootContext } from '../root/FieldsetRootContext';
import type { BaseUIComponentProps } from '../../internals/types';
+import { useRegisteredLabelId } from '../../utils/useRegisteredLabelId';
/**
* An accessible label that is automatically associated with the fieldset.
@@ -20,14 +19,7 @@ export const FieldsetLegend = React.forwardRef(function FieldsetLegend(
const { disabled, setLegendId } = useFieldsetRootContext();
- const id = useBaseUiId(idProp);
-
- useIsoLayoutEffect(() => {
- setLegendId(id);
- return () => {
- setLegendId(undefined);
- };
- }, [setLegendId, id]);
+ const id = useRegisteredLabelId(idProp, setLegendId);
const state: FieldsetLegendState = {
disabled,
diff --git a/packages/react/src/fieldset/root/FieldsetRoot.test.tsx b/packages/react/src/fieldset/root/FieldsetRoot.test.tsx
index c65fe5a6bb4..069854a2db3 100644
--- a/packages/react/src/fieldset/root/FieldsetRoot.test.tsx
+++ b/packages/react/src/fieldset/root/FieldsetRoot.test.tsx
@@ -1,5 +1,6 @@
import { expect } from 'vitest';
-import { createRenderer, screen } from '@mui/internal-test-utils';
+import * as React from 'react';
+import { createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
import { Checkbox } from '@base-ui/react/checkbox';
import { CheckboxGroup } from '@base-ui/react/checkbox-group';
import { Field } from '@base-ui/react/field';
@@ -42,6 +43,46 @@ describe('', () => {
expect(screen.getByTestId('control')).toHaveAttribute('disabled');
});
+ it('updates nested disabled precedence in both directions', async () => {
+ function App() {
+ const [outerDisabled, setOuterDisabled] = React.useState(false);
+ const [innerDisabled, setInnerDisabled] = React.useState(true);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ await render();
+
+ expect(screen.getByTestId('control')).toBeDisabled();
+ expect(screen.getByTestId('root')).toHaveAttribute('data-disabled');
+ fireEvent.click(screen.getByRole('button', { name: 'Disable outer' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Enable inner' }));
+ expect(screen.getByTestId('control')).toBeDisabled();
+ expect(screen.getByTestId('root')).toHaveAttribute('data-disabled');
+ fireEvent.click(screen.getByRole('button', { name: 'Enable outer' }));
+ expect(screen.getByTestId('control')).not.toBeDisabled();
+ expect(screen.getByTestId('root')).not.toHaveAttribute('data-disabled');
+ });
+
it('passes disabled to rendered Base UI roots', async () => {
await render(
diff --git a/packages/react/src/form/Form.test.tsx b/packages/react/src/form/Form.test.tsx
index db37542667e..c3285844aae 100644
--- a/packages/react/src/form/Form.test.tsx
+++ b/packages/react/src/form/Form.test.tsx
@@ -273,6 +273,9 @@ describe('', () => {
+
);
@@ -296,6 +299,13 @@ describe('', () => {
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit.mock.lastCall?.[0]).toEqual({});
+
+ await user.click(screen.getByRole('button', { name: 'Enable' }));
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ expect(handleSubmit).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId('control')).toHaveAttribute('aria-invalid', 'true');
+ expect(screen.getByTestId('error')).toBeInTheDocument();
});
it('clears invalid attributes when a field control becomes disabled', async () => {
diff --git a/packages/react/src/radio/root/RadioRoot.tsx b/packages/react/src/radio/root/RadioRoot.tsx
index ec42134d2ba..55bfe39d0fb 100644
--- a/packages/react/src/radio/root/RadioRoot.tsx
+++ b/packages/react/src/radio/root/RadioRoot.tsx
@@ -87,7 +87,7 @@ export const RadioRoot = React.forwardRef(function RadioRoot(
const registerFieldInput = validation?.registerInput;
const registerInput = React.useCallback(
- (element: HTMLInputElement | null) =>
+ (element: HTMLInputElement) =>
registerFieldInput?.(element, { controlRef: radioRef, value: undefined }),
[registerFieldInput],
);