Skip to content

[field] Preserve custom validity set outside the field - #5348

Closed
flaviendelangle wants to merge 6 commits into
mui:masterfrom
flaviendelangle:field-persist-foreign-custom-validity
Closed

[field] Preserve custom validity set outside the field#5348
flaviendelangle wants to merge 6 commits into
mui:masterfrom
flaviendelangle:field-persist-foreign-custom-validity

Conversation

@flaviendelangle

Copy link
Copy Markdown
Member

A control can own a validity condition that no native constraint expresses — a date field whose sections spell out February 30th, a range field whose bounds are reversed — and the natural way to surface it is setCustomValidity on the control's input. Today the field throws that away.

Form's submit handler sets submitAttemptedRef before calling field.validate() on each field, so shouldValidateOnChange() is already true for the submit pass itself. That pass takes the validate-branch in useFieldValidation.commit(), which calls clearCustomValidity() on every input — indiscriminately, including messages the field never set — forces customError to false, and then recomputes the field as valid from element.validity. Since Form renders noValidate, the published validityData is the only thing gating submission, so the form submits an invalid value and the <Field.Error> disappears.

The same clear runs on every subsequent change commit (submitAttemptedRef is never reset), so once a form has been submitted the message can't come back.

Changes

  • Track the custom validity messages this hook sets in a module-level WeakMap and clear only those. The message comparison is skipped when !element.willValidate, because elements barred from constraint validation report validationMessage === '' — that case is exercised by the existing CheckboxGroup disabled-input test.
  • Read nextState.customError back off element.validity instead of assuming the clear removed it.
  • Make publishAllValid bail when a foreign custom error survives the clear, so the on-change revalidation path can't publish an all-valid state over a message it doesn't own either.

Messages the field sets itself (from the validate prop) are cleared exactly as before.

Testing

Three tests in FieldRoot.test.tsx. The two "keeps a message set outside the field" tests fail on master; "clears the message it set itself" passes both before and after, as a guard against over-fixing.

Verified end to end against Base UI Plus' DateField, which surfaces its invalid-date state this way: with this change and no Plus-side edits, entering February 30th blocks <Form> submission and keeps the error visible, in both jsdom and Chromium.

🤖 Generated with Claude Code

`Form`'s submit handler sets `submitAttemptedRef` before validating, so the
submit pass itself runs as an on-change validation. That branch cleared the
custom validity of every input and recomputed the field as valid, discarding
messages the field never set.

A control can own a validity condition that no native constraint expresses (a
date field whose sections spell out February 30th, for example) and surface it
with `setCustomValidity`. Since `Form` renders `noValidate`, the published
validity is all that blocks submission, so dropping the message let the form
submit an invalid value and made the `Field.Error` disappear.

Track the messages set here and clear only those, read `customError` back off
the element instead of assuming it was cleared, and keep the on-change
revalidation path from publishing an all-valid state over a message it doesn't
own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

commit: ca17a16

@code-infra-dashboard

code-infra-dashboard Bot commented Jul 27, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+613B(+0.14%) 🔺+201B(+0.14%)

Details of bundle changes

Performance

Total duration: 1,051.27 ms -17.15 ms(-1.6%) | Renders: 78 (+0) | Paint: 1,638.13 ms -23.63 ms(-1.4%)

No significant changes — details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit ca17a16
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a79cd29e35a8f0008244e67
😎 Deploy Preview https://deploy-preview-5348--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

…e revalidation

Review follow-up:
- Publish the element's actual state when an external custom error survives
  the required-value revalidation pass, instead of leaving the stale
  `valueMissing` error on display. `publishAllValid` is void again and the
  clear-and-check lives at its single call site.
- Re-derive `valid` alongside the `customError` read-back so a message set
  while an async `validate` was pending cannot publish `valid: true` with
  `customError: true`.
- Drop the WeakMap entry once the hook observes its message was overwritten,
  so a stale entry can't wipe a foreign message from a barred element later.
- Pin the previously untested paths: the revalidation bail, the `customError`
  read-back, the overwritten-message refusal, recovery after the external
  message is withdrawn, a multi-line owned message round-trip, and a foreign
  message on a registered group input blocking submit.
- Refresh comments that still described the pre-ownership clearing behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flaviendelangle flaviendelangle self-assigned this Jul 27, 2026
@flaviendelangle flaviendelangle added type: bug It doesn't behave as expected. component: field Changes related to the field component. labels Jul 27, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flaviendelangle flaviendelangle added the component: form Changes related to the form component. label Jul 27, 2026

atomiks commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR review

Two merge-blocking validity-ownership defects remain, and a third multi-input path can publish a surviving error as valid. The existing Field and CheckboxGroup suites pass in jsdom and Chromium, but focused Chromium probes reproduced all three cases.

Bugs (3)

1. 🔴 Normalize owned messages before comparing them

Location: packages/react/src/field/root/useFieldValidation.ts:89

if (element.willValidate && element.validationMessage !== ownMessage) {
  ownedCustomValidity.delete(element);
  return;
}

Chromium normalizes \r and \r\n in custom validity messages to \n. The WeakMap stores the original string, so the comparison treats the browser-normalized message as an external replacement and abandons it without clearing it. The merge-base cleared messages unconditionally, making this a new regression.

Failure scenario: validate returns "error\r\nmore", then returns null after the value changes. The control retains "error\nmore" indefinitely, leaving the field and Form invalid.

Fix: Normalize line endings before calling setCustomValidity and storing the message, then add a CRLF case beside the multiline regression test.

2. 🔴 Restore an external error displaced by the field validator

Location: packages/react/src/field/root/useFieldValidation.ts:75

function setOwnCustomValidity(element: HTMLInputElement, message: string) {
  element.setCustomValidity(message);
  ownedCustomValidity.set(element, message);
}

Installing an owned error overwrites any existing control-owned custom error without retaining it. When validate later succeeds, clearing the owned message makes the control valid instead of restoring the still-applicable external condition. This existed at the merge-base, but leaves the PR’s preservation contract incomplete.

Failure scenario: A date control sets "invalid date". Field.Root.validate temporarily returns "must be future" and later returns null. The original invalid-date error disappears and the Form can submit the still-invalid value.

Fix: Retain a displaced custom message when installing an owned one and restore it when that owned message is cleared, unless external code has since replaced or withdrawn it. Add the reverse-order counterpart to the new “external overwrote own” test.

3. 🟠 Recompute the representative input after clearing errors

Location: packages/react/src/field/root/useFieldValidation.ts:338

clearCustomValidity(element, registeredInputs);

nextState.customError = element?.validity.customError ?? false;

element is selected before clearing. For CheckboxGroup and RadioGroup, clearing an owned error can make another registered input—with a preserved external error—the new representative, but the code continues reading the stale input and publishes the entire field as valid.

Failure scenario: Input A contains the field validator’s error and input B contains an external error. When the validator succeeds, A is cleared while B remains custom-invalid, but Field.Error, aria-invalid, and Field.Validity all become valid. With an async on-change validator, the stale state also allowed onFormSubmit to fire in both jsdom and Chromium probes.

Fix: After clearing—and after any awaited validation—re-run findRepresentativeInput and derive the complete state and message from the current representative.

Verdict

Request changes - the normalized-message regression and displaced external errors can leave Forms permanently blocked or incorrectly submit invalid values.


🤖 Review generated with Codex

Chromium reports `\r\n` and `\r` back as `\n`, so a multi-line owned message
read as foreign on the next pass and was stranded on the control. Match
ownership on normalized text.

Installing an owned message over one set by other code hid a condition this
hook knows nothing about, and clearing the owned message then reported the
control as valid. Remember the displaced message and hand the control back to
it instead.

Clearing an owned message can also promote another registered input — one that
kept a message of its own — to representative, so resolve it again afterwards
and derive the published state from it rather than from the stale one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@flaviendelangle

Copy link
Copy Markdown
Member Author

Applied all three in 38c8246.

  1. Normalized message comparison. Ownership is now matched on \r\n/\r\n normalized text, both when storing and when comparing, so a multi-line owned message isn't read back as foreign. Covered by a CRLF case next to the multi-line test.

  2. Displaced messages restored. setOwnCustomValidity records a custom error it is about to overwrite, and clearing the owned message hands the control back to that message instead of blanking it. If outside code replaced or withdrew the message in the meantime, the mismatch branch drops both records and leaves the control alone. Covered by the reverse-order counterpart of the "external overwrote own" test.

  3. Representative re-resolved after clearing. The on-change branch resolves findRepresentativeInput again after clearCustomValidity and derives the whole state and message from that input, which also picks up DOM changes that happened while an async validate was pending. Covered by a CheckboxGroup test where the validator's error sits on one input and an external error on another.

All three tests fail on the previous commit in Chromium and pass with the fix. field, form, checkbox-group, radio-group, select and number-field suites are green in both jsdom and Chromium.

🤖 Generated with Claude Code

@michaldudak

Copy link
Copy Markdown
Member

PR review

The ownership-tracking approach is sound and well-tested, but two verified bugs survive — both independently flagged by a second reviewer and confirmed here with failing repro tests against this branch. The displacedCustomValidity restore can resurrect a foreign message the outside code already withdrew — a sticky phantom error that blocks form submission in the PR's own motivating DateField scenario — and the revalidate fast path still publishes all-valid over a surviving foreign message in multi-input groups. The first is merge-blocking.

Bugs (4)

1. 🔴 Stale displaced entry resurrects a withdrawn foreign message

Location: packages/react/src/field/root/useFieldValidation.ts:103

function setOwnCustomValidity(element: HTMLInputElement, message: string) {
  if (element.willValidate && element.validity.customError && !hasOwnCustomValidity(element)) {
    displacedCustomValidity.set(element, element.validationMessage);
  }
  ...

(Found independently by both reviewers; confirmed by failing repro.) When the hook takes ownership of an element that currently has no custom error, it neither records nor deletes a displacedCustomValidity entry left over from an earlier displacement. If the foreign code withdrew its condition in between (via setCustomValidity(''), which necessarily also wipes the hook's message — it can't know whose message is on the control), the stale entry survives, and the next clearOwnCustomValidity restores it (useFieldValidation.ts:126). Because the restore also deletes ownedCustomValidity, the resurrected message then looks foreign and is preserved by design on every later commit — a phantom error that keeps customError: true, keeps <Field.Error> visible, and keeps Form from submitting until the foreign code happens to clear again.

Failure scenario: Verified with a failing jsdom repro on this branch — validationMode="onChange", single Field.Control: (1) foreign code sets 'invalid date'; (2) validate fails → 'invalid date' recorded as displaced; (3) foreign code resolves its condition and calls setCustomValidity(''); (4) validate fails again → owned message re-set, stale displaced entry untouched; (5) validate passes → clear restores 'invalid date'. The control and <Field.Error> show a withdrawn error and the form can't submit, even though every condition is resolved. This is the PR's motivating integration (external date validator + validate prop interleaving).

Fix: In setOwnCustomValidity, when element.willValidate && !element.validity.customError, delete the displacedCustomValidity entry before installing the next owned message — no custom error on the control means any previously displaced message has been withdrawn. Keep the entry only when the current custom error is the owned message itself (still legitimately displaced) or a fresh foreign message (which overwrites it). Add the five-step repro above as a regression test covering withdrawal while the owned error continues.

2. 🟠 Revalidate fast path misses a foreign message on a non-representative group input

Location: packages/react/src/field/root/useFieldValidation.ts:244

clearCustomValidity(element, registeredInputs);

if (!element.validity.customError) {
  ...
  publishAllValid(false);

(Found independently by both reviewers; confirmed by failing repro.) element is resolved before clearCustomValidity. In a multi-input field, the pre-clear representative is the first invalid input — typically the one carrying the hook-owned message. After the clear, that input is valid, so element.validity.customError is false and publishAllValid(false) runs even when another registered input still carries a foreign message. The sibling isValidatingOnChange branch re-resolves the representative after clearing for exactly this reason (useFieldValidation.ts:379); this revalidation shortcut doesn't, so the invariant the PR introduces still breaks on the path it also rewired — and the new group test only covers the onChange (validate-branch) variant.

Failure scenario: Verified with a failing jsdom repro — checkbox group, validationMode="onBlur": blur commits 'own error' onto input A; foreign code sets 'external error' on input B; user toggles a checkbox → revalidate path clears A's owned message, sees customError: false on A, and publishes all-valid. <Field.Error> disappears, aria-invalid/data-invalid are removed, and Field.Validity reports valid: true while B still holds 'external error' — incorrect until the next blur/submit boundary re-validates.

Fix: After clearCustomValidity, re-resolve the representative (const currentElement = resolveRepresentativeInput() ?? element) and use it for the custom-error check, the published validity state, and the message. Add the corresponding onBlur grouped-input regression test.

3. 🟡 Foreign-surviving revalidate publish surfaces deferred native errors mid-typing

Location: packages/react/src/field/root/useFieldValidation.ts:259

const nextValidityData = {
  value,
  state: getState(element),
  ...

The revalidate path deliberately defers native errors other than valueMissing to the blur/submit boundary (the comment at lines 247–249 and the validityKeys loop below both enforce this). But the new foreign-surviving branch publishes the element's full native state, so any co-occurring native error (typeMismatch, patternMismatch…) becomes visible during typing — only when a foreign message happens to be present.

Failure scenario: <Field.Control type="email" required />, validationMode="onBlur", field invalid via valueMissing, foreign message set. User types abc → the published state has typeMismatch: true, so <Field.Error match="typeMismatch"> renders mid-typing; without the foreign message the same keystroke publishes all-valid. Consumers see a mode-dependent, flickering native error.

Fix: Publish only the surviving custom error on this path, e.g. state: { ...DEFAULT_VALIDITY_STATE, valid: false, customError: true }, keeping other native errors deferred as documented.

4. ℹ️ Barred elements bypass displacement recording and ownership verification

Location: packages/react/src/field/root/useFieldValidation.ts:98

For willValidate === false elements (disabled inputs, Select's hidden serialization input), validationMessage is unreadable, so setOwnCustomValidity skips displacement recording (a foreign message present at that moment is wiped with no record) and hasOwnCustomValidity assumes ownership blindly — clearCustomValidity iterates all registered inputs regardless of eligibility, so a foreign message updated on a disabled input can be overwritten by a stale displaced restore. Both flows require outside code writing custom validity to barred elements, which is not realistically reachable in normal consumer usage — flagging as a heads-up since only the first half of this blind spot is covered by the code comment.

Simplifications (1)

1. 🟡 Unreachable branch left behind after the fresh state re-read

Location: packages/react/src/field/root/useFieldValidation.ts:388

} else if ((!currentElement || currentElement.validity.valid) && !nextState.valid) {
  nextState.valid = true;
}

Before this PR, nextState was a pre-clear snapshot, so this fix-up could fire. Now Object.assign(nextState, getState(currentElement)) runs first, and getState copies validity.valid verbatim (its valueMissing softening only flips false → true), while the null-element case assigns valid: true. Every combination makes the condition false, so the branch is dead weight in a bundle-size-sensitive file.

Failure scenario: Three unreachable lines that future readers must reason about (and that suggest a state divergence which can no longer occur).

Fix: Delete the else if branch.

Verdict

Request changes — the confirmed displaced-message resurrection (finding 1) creates a sticky, submit-blocking phantom error in the exact integration this PR exists to support.


🤖 Review generated with Claude Code

@atomiks

atomiks commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

PR review

The latest revision fixes the three earlier findings, and CI is green. One merge-blocking ownership defect remains, plus the stale representative bug still exists in the separate onBlur revalidation path.

Bugs (2)

1. 🔴 Drop stale displaced errors before installing another owned error

Location: packages/react/src/field/root/useFieldValidation.ts:105

function setOwnCustomValidity(element: HTMLInputElement, message: string) {
  if (element.willValidate && element.validity.customError && !hasOwnCustomValidity(element)) {
    displacedCustomValidity.set(element, element.validationMessage);
  }

  element.setCustomValidity(message);
  ownedCustomValidity.set(element, normalizeValidationMessage(message));
}

When outside code withdraws its error by calling setCustomValidity('') while the field’s own error is displayed, the WeakMaps still contain both the old ownership record and the displaced external message.

If validate continues returning an error on the next commit, element.validity.customError is now false, so the stale displaced entry is not removed. The new owned message is installed while the old external message remains queued. Once validate finally returns null, clearOwnCustomValidity resurrects that withdrawn external error.

This ordering is realistic with Field.Control: onValueChange runs before validation.change, allowing consumer code to clear its custom validity before the field validator runs again.

Failure scenario: A date control sets "invalid date", while Field.Root.validate sets "must be in range". The user fixes the date but remains outside the allowed range, so the date control clears its message and the field installs its own error again. After the user fixes the range, "invalid date" is restored even though that condition was already withdrawn, leaving the field and Form permanently invalid.

Fix: At the beginning of setOwnCustomValidity, reconcile any existing ownership record. When an entry exists but hasOwnCustomValidity(element) is now false, delete the previous displaced entry before capturing the current message and installing the new owned one.

Add a regression test with three commits:

  1. External error is displaced by an owned error.
  2. External error is cleared while validate continues failing.
  3. validate succeeds and the field becomes valid rather than restoring the external error.

2. 🟠 Re-resolve the representative in the revalidation branch

Location: packages/react/src/field/root/useFieldValidation.ts:245

clearCustomValidity(element, registeredInputs);

if (!element.validity.customError) {
  publishAllValid(false);
  return;
}

element was selected before owned errors were cleared. In a CheckboxGroup or RadioGroup, clearing an owned error from input A can make input B—with a preserved external custom error—the new representative. This branch nevertheless checks the now-valid input A and publishes the whole field as valid.

The later on-change success branch correctly calls resolveRepresentativeInput() after clearing, but this earlier path does not. validationMode="onBlur" reaches it because changes call commit(value, true) whenever change validation is disabled.

Failure scenario: An on-blur CheckboxGroup validator places its error on the first hidden input. A separate control-owned error is then placed on the second input. On the next checkbox change, the first error is cleared, the second remains invalid, but Field.Error, data-invalid, aria-invalid, and Field.Validity all switch to valid until another blur or submission.

Fix: Resolve the representative again immediately after clearCustomValidity, as the later branch already does, and derive the custom-error state and message from that current representative. Add an onBlur CheckboxGroup counterpart to the existing multi-input on-change test.

Verdict

Request changes — the stale displaced-message entry can resurrect a resolved error and indefinitely block valid Form submission.


🤖 Review generated with GPT-5.6 Thinking

Outside code can withdraw its message while the field's own error is displayed.
Clearing wipes the single slot both share, so a displaced record left from an
earlier pass no longer describes a live condition. Drop it when the control
carries no custom error instead of restoring a withdrawn message later, which
kept the field — and the surrounding Form — invalid for good.

Change revalidation resolved its representative input before clearing owned
messages, so a group whose remaining error sits on another input was published
as valid. Resolve it again after the clear, and publish only the surviving
custom error so co-occurring native errors stay deferred to the blur or submit
boundary like the resolved `valueMissing` one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@flaviendelangle

Copy link
Copy Markdown
Member Author

Thanks both — all findings are addressed in ce28649.

🔴 Stale displaced entry resurrects a withdrawn foreign message — fixed. setOwnCustomValidity now reconciles the record before installing an owned message: when the control carries no custom error, the displaced entry is dropped (outside code withdrew it — clearing wipes the single slot both messages share). A fresh foreign message still overwrites the record, and an owned message already in place keeps it. Regression test covers the three-commit sequence: external error displaced → withdrawn while validate keeps failing → validate succeeds and the field ends up valid instead of restoring the withdrawn message.

🟠 Revalidate fast path misses a foreign message on a non-representative group input — fixed. The representative is re-resolved after clearCustomValidity (resolveRepresentativeInput() ?? element) and drives the custom-error check, the published state, and the message, matching what the on-change success branch already did. Added the onBlur CheckboxGroup counterpart to the existing on-change group test.

🟡 Foreign-surviving revalidate publish surfaces deferred native errors mid-typing — fixed. That path now publishes { ...DEFAULT_VALIDITY_STATE, valid: false, customError: true } instead of the element's full native state, so co-occurring native errors stay deferred to the blur/submit boundary like the resolved valueMissing one. Covered by a type="email" required test asserting typeMismatch doesn't surface while a foreign message survives.

🟡 Unreachable branch after the fresh state re-read — deleted. Confirmed dead: getState copies validity.valid verbatim and the null-element case assigns valid: true, so no combination made the condition true.

ℹ️ Barred elements bypass displacement recording and ownership verification — left as-is behaviorally, since it needs outside code writing custom validity to a barred element (validationMessage is unreadable there, so neither half can be done reliably). Extended the comment to document the displacement-recording half of the blind spot too, not just the ownership one.

All three new tests were verified to fail against the previous revision and pass with the fix.

@atomiks

atomiks commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

PR review

No finding blocks merge outright, but one is close: controls that maintain external custom validity from a React effect are published one event late in both directions, so a withdrawn message visibly lingers as a stale <Field.Error> until the next validation boundary — and the PR's own test helper models exactly that effect-driven pattern. The remaining theme is that the ownership machinery is heavier than the single-holder job needs and harbors two narrow bookkeeping gaps. CI is green across jsdom/browser/React 18/Compiler, and only this file touches setCustomValidity in the package.

Bugs (4)

1. 🟠 Effect-driven external validity publishes one event late; withdrawn messages linger as stale field state

Location: packages/react/src/field/root/useFieldValidation.ts:394

const currentElement = resolveRepresentativeInput();
Object.assign(
  nextState,
  currentElement ? getState(currentElement) : { ...DEFAULT_VALIDITY_STATE, valid: true },
);

commit reads the control's validationMessage synchronously inside the change event. A controlled widget that derives setCustomValidity from its value in an effect updates the DOM only after that read, so every publish reflects the previous external state: the error appears one event late, and after the user corrects the value the field keeps publishing the withdrawn message — data-invalid and <Field.Error> stay up while input.validationMessage is already '' — until the next change, blur, or submit re-commits.

Failure scenario: an on-change date field shows "invalid date" for February 30th; the user corrects it to the 28th on their final keystroke; the input is natively valid but the error and invalid styling stick until blur.

Fix: this is inherent to pull-based ownership, and post-effect resync machinery isn't worth its bytes; instead make the contract explicit: external setCustomValidity must be applied synchronously during change handling, not in an effect. Update the ExternallyInvalidControl test helper (or add a sibling test with a dynamic effect-driven message) so the suite doesn't teach the broken pattern, and document the sync-set expectation in the ownership comment. If effect-driven controls are meant to be first-class consumers of this feature, that's an API-level follow-up (a validity-notification hook), not a patch on this diff.

2. 🟡 validate returning an empty array corrupts the displaced-message record and drops a preserved external message

Location: packages/react/src/field/root/useFieldValidation.ts:379 interacting with useFieldValidation.ts:108-112

if (!element.validity.customError) {
  displacedCustomValidity.delete(element);
}

validate may legally return string[], and [] marks the field invalid (pre-existing contract) while result.join('\n') is '' — so setOwnCustomValidity(element, '') empties the shared slot while recording ownership of ''. On the next [] commit, element.validity.customError is false, which trips the "displaced message was withdrawn" heuristic and deletes the record the hook itself invalidated. While '' is the owned record, hasOwnCustomValidity also returns true whenever the control is otherwise valid, muddying later ownership decisions.

Failure scenario: onChange field whose validate returns a message array, wrapped around a widget that set 'Invalid date'. Keystroke 1 ([]): message displaced-recorded, slot cleared. Keystroke 2 ([]): record deleted. When validate later returns null, the restore yields '' — the still-applicable external message is gone, the exact loss this PR prevents, one legal return shape away. (Not a regression vs master, which destroyed it on keystroke 1.)

Fix: don't treat an empty slot as withdrawal when the owned message is itself empty — guard the delete with ownedCustomValidity.get(element) !== '', or never record '' as owned. Add a test with validate returning [] across two commits with an external message present.

3. 🟡 willValidate === false blind spot: messages changed on a barred control are clobbered or wrongly restored

Location: packages/react/src/field/root/useFieldValidation.ts:98

return (
  !element.willValidate || normalizeValidationMessage(element.validationMessage) === ownMessage
);

A barred control (disabled, readonly) reports validationMessage === '', so hasOwnCustomValidity unconditionally trusts the owned record. If external code replaces — or withdraws — the slot's contents while the control is barred, the registry-wide clear still treats the record as live: a replacement foreign message is overwritten with '' (or a now-stale displaced message is restored), and the loss is permanent once the control is re-enabled. The code comment acknowledges only the set-path half of this blind spot; the clear path and the stale-displaced-restore are undocumented.

Failure scenario: CheckboxGroup where the validator's message lands on checkbox A; A is disabled; a widget calls A.setCustomValidity('external error') while barred; checking B resolves the validator and the clear loop wipes A's foreign message for good.

Fix: deferring cleanup until the control is eligible again would cost state and bytes for an edge-of-edge — I'd accept the behavior and extend the comment to cover the clear path, so the tradeoff is a documented decision rather than an accident.

4. ℹ️ Identical foreign message is cleared as owned

Location: packages/react/src/field/root/useFieldValidation.ts:91-100

Ownership is matched by normalized text, so an external message textually identical to the currently-owned one (e.g. both derived from the same i18n string) is indistinguishable and gets cleared when the validator resolves. Acknowledged in the doc comment; no cheap fix under text matching — flagging for a conscious sign-off only.

Tests (1)

1. 🟡 No test combines an async validate with an externally-set message

Location: packages/react/src/field/root/FieldRoot.test.tsx:964

The rewritten clean branch re-resolves the representative and re-reads validity specifically because "an awaited validate may have let the DOM move on" — but all 13 new tests use synchronous validate, and the existing async race tests don't involve external messages. A refactor moving the clear/re-read above the await would pass every current test while dropping an external message that appears mid-validation.

Fix: add one test with a deferred-promise validate where setCustomValidity('external error') fires while validation is pending and the promise resolves null; assert the message survives and is published.

Simplifications (1)

1. 🟠 The ownership machinery can be roughly half the bytes: per-hook record instead of module-level WeakMaps

Location: packages/react/src/field/root/useFieldValidation.ts:73-148

setOwnCustomValidity only ever targets the current representative, so the hook owns at most one message at a time; two module-scope WeakMaps plus a clear loop probing the whole registry is over-machinery for a single-holder invariant. A per-hook ref record { element, message, displaced } does the same job and is tighter — clearing the old record on representative change instead of leaving a stale owned message installed until the next clear pass. Independently, nextValidityData is now built three times (publishAllValid, the surviving-external branch, the final publish) with error === errors[0] ?? '' holding in every branch; one shared publish(state, errors, externalInvalid?) helper removes the duplicates and the defaultValidationMessage variable.

Measured on the 2026-07-28 state of this branch against the @base-ui/react/field entrypoint: the PR cost +605 parsed / +187 gzipped; the ref-record + publish-dedup form of the same behavior cost +324 / +145, recovering ~46% of the parsed cost with the then-current tests passing in jsdom and Chromium. The head has grown slightly since, but the structure carries over, and the fixes for bugs 2 and 3 fold naturally into the record shape. A working uncommitted version exists on my pr-5348-analysis branch if useful.

Verdict

Approve after nits — the effect-driven staleness is real but is the inherent off-by-one of pull-based ownership with a documented sync-set workaround, so I'd resolve it via contract and tests rather than block; the remaining findings are narrow bookkeeping gaps and a measured size recovery worth taking before merge.


🤖 Review generated with Claude Code

@atomiks

atomiks commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Addressed in #5449

@atomiks atomiks closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: field Changes related to the field component. component: form Changes related to the form component. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants