fix(fields): announce LocationField's refusals instead of swallowing the edit - #6754
Merged
os-sales merged 1 commit intoAug 29, 2026
Merged
Conversation
…the edit `LocationField` refuses to emit for input it cannot accept and said nothing when it did — the format arm (text that is not a comma-separated pair) since long before the range arm (a pair the spec refuses) was added. `onChange` never fires on a refusal, so the typed text vanished and `aria-invalid` read "false" through both. The widget now owns a `refusalError` state, OR-ed into `aria-invalid` and rendered as its own short line, following `ObjectField`'s `parseError` precedent: the published objectui#3222 `error` slot keeps exactly one author (the form renderer). The range message is built from `LocationValueSchema`'s own issues rather than a hand-copied range. The box also holds the refused text now. That was measured, not assumed: the minimal shape was built first, and without a draft React restores the control in the same tick, so the message pointed at an empty box and typing a valid coordinate one character at a time stored nothing while lighting a refusal on the last keystroke too. Refusal itself is unchanged — nothing the platform validator rejects is emitted or stored, and `buildValidationRules` still has no `location` branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
os-sales
marked this pull request as ready for review
August 29, 2026 06:38
os-sales
deleted the
claude/issue-6716-location-field-refusal-diagnostic
branch
August 29, 2026 06:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6716
LocationFieldrefuses to emit for input it cannot accept, and said nothing when it did. Two refusals shared that silence: text that is not a comma-separated pair (pre-existing, e.g.not a coordinate) and a pair outside the spec's coordinate range (added by objectui#6714, e.g.999, 999). In both,onChangeis never called, so the typed text disappears and the control reportsaria-invalidas "false" — a screen reader is told the field is fine immediately after it rejected the entry.Reproduced first, on the base commit
faac0d935Driving a real
ObjectForm(create mode, atype: 'location'field, a fake DataSource), my own readings before changing anything:999, 999{ title: 'HQ' }, noplacekeynot a coordinate{ title: 'HQ' }, noplacekey30.2741, 120.155130.2741, 120.1551place: { lat: 30.2741, lng: 120.1551 }And the reading that decided the shape below — typing a valid
30.27, 120.15one character at a time on the base commit: the box read empty after all 13 keystrokes and the form storedplace: null. The field cannot be filled in by typing at all today.The route: widget-local, following
ObjectFieldThe first dispatch routed this to
buildValidationRules, to keep one producer for the published objectui#3222errorslot. That was measured infeasible on the previous run and is not re-attempted here: a refusal meansonChangenever fires, so the typed text never becomes a form value and alocationbranch installed there is handedundefinedin both arms, while the same branch fires correctly for a stored out-of-range pair.buildValidationRulesstill has nolocationbranch, and this PR does not give it one — there is a pin asserting exactly that, so the sibling objectui#6714 file's docblock stays true.So the state is the widget's own, copying
ObjectField.tsxin the same directory, naming discipline included:refusalError, nevererror. The published objectui#3222 slot keeps exactly one author (the form renderer), and the widget does not render the host's message text.aria-invalidis the OR of the two.LocationValueSchema's own issues, never from a hand-copied coordinate range — a copied bound is the second contract AGENTS.md #0.1 bans, and it is the exact failure objectui#6714 was about.The snap-back: minimal shape built and measured FIRST
The order was to build the minimal shape (refusal state only, controlled-input semantics untouched), measure whether it is coherent, and only then consider holding the draft. That is what happened, and the minimal shape is measurably incoherent:
With the box's value still derived from the stored value, a refusal produces no state update the control's value depends on, so React restores it in the same tick. Typing a valid
30.27, 120.15one character at a time under the minimal shape:aria-invalid"true" plus the format sentence — after 12 of the 13, including the last one, i.e. it accused a person of typing an invalid coordinate when they had typed a valid one;place: null.With no draft, "refused" and "still typing" are the same state, and the message points at an empty box. Announcing the refusal honestly requires holding the text that was refused — which is why
ObjectFieldcouples the two. So the coupled shape is what shipped, and it is stated loudly here because it changes controlled-input semantics that triage fenced.Same interaction on this branch: the draft accumulates, the diagnostic clears at the first keystroke that makes a pair the spec accepts, and the form stores
{ lat: 30.27, lng: 120.15 }.The draft sync is deliberately triggered by the value changing, not by "the draft disagrees with the value". The second rule was written first and measured wrong: a host that does not echo an emission back (an
onChangespy, a debounced or normalising host) leaves the value behind the draft permanently, so the rule erased text as it was typed and left20.15in the box. There is a pin for that, and one for adopting a value that arrives from outside the box.What did NOT change
placefor either arm.FieldWidgetComponentProps) was refused by the dispatch and is not taken.packages/plugin-form/src/ObjectForm.locationRange.test.tsxis not edited; its statement thatbuildValidationRuleshas nolocationbranch remains true, and the new file pins it.Verification, all on
f40389731pnpm exec vitest run packages/fields/ packages/plugin-form/—Test Files 191 passed (191),Tests 2665 passed (2665), lock verdictVERDICT command-exit 0.pnpm --filter @object-ui/fields --filter @object-ui/plugin-form run type-check— bothDone(each istsc --noEmitplustsc -p tsconfig.test.json, so the new test files are covered; an earlier run proved that by failing inside one of them).0 errors, 9 warnings, allno-explicit-anyin test files, matching the sibling tests' style. Narrowed run, declared: the file population is eslint's own config, which this diff does not touch; the count comes from the JSON formatter (3 files, exactly the changed ones); and this repo configures no type-aware linting (noprojectService, noparserOptions.project), so no untouched file's verdict can move because of this diff. The repo-wide sweep is CI's.check:changeset-presence,check:changeset-no-major,check:control-bytes,check:phantom-deps— all reported OK.Ablation (against the committed implementation)
Removed the refusal wiring from the committed file — the
aria-invalidOR and the rendered line — with an absolute-path restore trapped on EXIT INT TERM.d078d153becamef7c4d993.Tests 13 failed | 14 passed (27), exit 1 — including both arms' announcement pins, both correction pins, and the host-error OR pin.git checkout HEAD -- (absolute path), blob back tod078d153equal to that path's HEAD blob,git diff HEADzero lines, zero leftover markers.Tests 27 passed (27), exit 0.vitest.config.mtsaliases@object-ui/fieldstopackages/fields/src, so both the widget tests and theObjectFormtests read source — nodistsits between the mutation and the reading.CI is the authority on the full gate farm; nothing here is reported as green that was not run.
Generated by Claude Code