Skip to content

fix(fields): announce LocationField's refusals instead of swallowing the edit - #6754

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6716-location-field-refusal-diagnostic
Aug 29, 2026
Merged

fix(fields): announce LocationField's refusals instead of swallowing the edit#6754
os-sales merged 1 commit into
mainfrom
claude/issue-6716-location-field-refusal-diagnostic

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #6716

LocationField refuses 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, onChange is never called, so the typed text disappears and the control reports aria-invalid as "false" — a screen reader is told the field is fine immediately after it rejected the entry.

Reproduced first, on the base commit faac0d935

Driving a real ObjectForm (create mode, a type: 'location' field, a fake DataSource), my own readings before changing anything:

typed aria-invalid box after the change event field row text create payload
999, 999 "false" empty "Place" (label only) { title: 'HQ' }, no place key
not a coordinate "false" empty "Place" (label only) { title: 'HQ' }, no place key
30.2741, 120.1551 "false" 30.2741, 120.1551 "Place" place: { lat: 30.2741, lng: 120.1551 }

And the reading that decided the shape below — typing a valid 30.27, 120.15 one character at a time on the base commit: the box read empty after all 13 keystrokes and the form stored place: null. The field cannot be filled in by typing at all today.

The route: widget-local, following ObjectField

The first dispatch routed this to buildValidationRules, to keep one producer for the published objectui#3222 error slot. That was measured infeasible on the previous run and is not re-attempted here: a refusal means onChange never fires, so the typed text never becomes a form value and a location branch installed there is handed undefined in both arms, while the same branch fires correctly for a stored out-of-range pair. buildValidationRules still has no location branch, 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.tsx in the same directory, naming discipline included:

  • refusalError, never error. The published objectui#3222 slot keeps exactly one author (the form renderer), and the widget does not render the host's message text.
  • aria-invalid is the OR of the two.
  • The range sentence is built from 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.15 one character at a time under the minimal shape:

  • the box read empty after all 13 keystrokes;
  • the refusal was lit — 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;
  • the form stored 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 ObjectField couples 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 onChange spy, a debounced or normalising host) leaves the value behind the draft permanently, so the rule erased text as it was typed and left 20.15 in the box. There is a pin for that, and one for adopting a value that arrives from outside the box.

What did NOT change

  • objectui#6714 is not reversed. Every refusal still refuses: nothing the platform validator rejects is emitted, and the end-to-end pin asserts the create payload still carries no place for either arm.
  • The published widget contract is untouched — no new prop, no widened type. Option B (a widget-to-host refusal channel on FieldWidgetComponentProps) was refused by the dispatch and is not taken.
  • packages/plugin-form/src/ObjectForm.locationRange.test.tsx is not edited; its statement that buildValidationRules has no location branch remains true, and the new file pins it.

Verification, all on f40389731

  • pnpm exec vitest run packages/fields/ packages/plugin-form/Test Files 191 passed (191), Tests 2665 passed (2665), lock verdict VERDICT command-exit 0.
  • pnpm --filter @object-ui/fields --filter @object-ui/plugin-form run type-check — both Done (each is tsc --noEmit plus tsc -p tsconfig.test.json, so the new test files are covered; an earlier run proved that by failing inside one of them).
  • eslint on the three changed files — 0 errors, 9 warnings, all no-explicit-any in 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 (no projectService, no parserOptions.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-invalid OR and the rendered line — with an absolute-path restore trapped on EXIT INT TERM.

  • Mutation proven on disk before any reading: 2 injected markers present, 0 occurrences of the OR left, blob d078d153 became f7c4d993.
  • Red leg: Tests 13 failed | 14 passed (27), exit 1 — including both arms' announcement pins, both correction pins, and the host-error OR pin.
  • Restore proven, not assumed: git checkout HEAD -- (absolute path), blob back to d078d153 equal to that path's HEAD blob, git diff HEAD zero lines, zero leftover markers.
  • Green leg after restore: Tests 27 passed (27), exit 0.
  • No rebuild leg is owed and here is why: the root vitest.config.mts aliases @object-ui/fields to packages/fields/src, so both the widget tests and the ObjectForm tests read source — no dist sits 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

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3178.4 KB 3222.7 KB
Main entry chunk (gzip) 148.2 KB 350 KB
Entry file index-Dyqya8FA.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 511.66KB 116.30KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 240.41KB 60.56KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.89KB 12.91KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.44KB 34.48KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.43KB 62.45KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.96KB 32.63KB
plugin-gantt (index.js) 165.20KB 40.37KB
plugin-grid (index.js) 201.53KB 54.54KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 113.11KB 27.58KB
plugin-map (index.js) 20.17KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.44KB 7.59KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 67.73KB 22.54KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(fields): LocationField refuses input silently — the validation slot it wires has no producer

2 participants