Skip to content

fix(fields): announce bad input across the type="number" widget class - #6801

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6780-badinput-announce
Aug 30, 2026
Merged

fix(fields): announce bad input across the type="number" widget class#6801
os-sam merged 2 commits into
mainfrom
claude/issue-6780-badinput-announce

Conversation

@claude

@claude claude Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #6780

Implements the 2026-08-29 ruling (option A): announce when
e.target.validity.badInput is true, across the whole type="number" widget
CLASS as one change — CurrencyField, PercentField, NumberField,
GeolocationField — reusing the refusal shape #6716 introduced for LocationField.

Terrain, re-derived on my own base

Base d06059f24. Two corrections to the dispatch note, both on that same ref:

  • ⚠️ badInput is NOT absent from the repo. PR docs(fields): record the measured browser reading behind CurrencyField/PercentField #6777 (c6732825d) is an
    ancestor of d06059f24, and it left 11 occurrences — a measurement suite plus
    comments in CurrencyField and PercentField. There WAS an in-repo record to
    build on, and this PR builds on it. (The dispatch note's other hit, an onBlur
    comment in data-table.tsx, does not exist; that file has no badInput.)
  • ✅ Confirmed: all four widgets live in packages/fields/src/widgets/, and
    PercentField has no onBlur today. Neither do NumberField or
    GeolocationField, so all three gain one here.

Is the guard real, or a second no-op?

#6715's anchored guard was measured to be a provable no-op in this exact area,
so this was measured before it was written — Chromium 141.0.7390.37 via
Playwright, typing key by key into a real number input served over
http://127.0.0.1:

typed .value badInput box displays it?
1e, 1e-, 1e+, 5e, -, ., +, -., e "" true yes — screenshot-compared against an untouched box
12, 1.23, 010, 15, 1, 12.345 as typed false n/a

It fires on nine keyboard-reachable states and on nothing a real browser
emits.
That is the opposite of #6715's result here, and it is why this is
worth shipping.

⚠️ A correction to the claim the whole option rested on

The dispatch order said badInput is "the ONE signal happy-dom and Chromium
agree on", and asked me to stop if that measured false. Measured, it is too
strong
— but not in a way that undermines option A, so I proceeded and
recorded the narrower truth instead.

  • In Chromium badInput is never true for a programmatic .value write.
    It reports that the agent cannot convert the user's input; a script write has
    no user input to fail on. So on the only route a happy-dom unit test has
    (fireEvent.change, which sets .value then dispatches), Chromium answers
    false for every string and happy-dom answers true for nine — on that route the
    two engines agree on nothing.
  • The agreement this guard genuinely rests on is between happy-dom's
    programmatic verdict and Chromium's TYPED verdict. It holds for a real subset:
    four strings agree on "bad", seven agree on "fine", and eight disagree.

⇒ Consequence, and the reason this matters rather than being trivia: a unit test
here may drive ONLY the agreeing subset. Driving 0x10 or 12abc would go green
over a branch the product never executes — the exact failure #6765 exists to
prevent. The full matrix and the allowed lists are pinned in
packages/fields/src/__tests__/numberInputBrowserReadings.ts, the overstated
one-liner is corrected where it was written, and the disagreement is now an
assertion, not a comment.

It announces; it deliberately does not refuse

#6716's shape refuses (its onChange never fires). Here that would destroy the
very text the diagnostic points at. Measured in both halves:

  • Chromium: after typing 1e, a script write of .value clears the raw display
    and flips badInput back to false — even writing the empty string.
  • React 19.2.8's own updateInput restores a number input with
    if ((0 === value && "" === element.value) || element.value != value).

So for a box that already held 5, refusing leaves the React value at 5 while
the element value is empty, and React writes "5" back over the 5e the user is
looking at. Emitting keeps the two equal, the write is skipped, and the raw text
survives — which is what #6716 requires of a refusal.

The blur arm

Pasting 1e into an empty box fires one DOM input event but never moves
.value off empty, so React's input-value tracking suppresses the synthetic
change entirely; badInput is still true at blur. The three widgets that gain an
onBlur compose any handler a host supplied rather than replacing it —
onBlur is a declared DOM pass-through key, and a bare handler after the
toDomProps spread would silently drop it.

Second ruling condition: truncation stays silent, in writing

Binding, not a nicety. 1.2.3 stores 1.23 and 0x10 stores 10, and no
widget-side guard can refuse them — the browser discards the characters before
any handler runs. Documented in content/docs/guide/fields.md (canonical), on
the currency / percent / number field pages, and in the package README, each
stating plainly that no warning does not mean the value is right.

Ablation, both directions

From the committed tree, one lock acquisition, absolute-path restore trap,
mutation proven on disk by counting injected and removed text, restore proven by
git diff HEAD empty and blob hash equal to the HEAD blob
(6de8befe06fa505d116189b0bb97f6f417a2d6cb) after each leg. Runs on source —
the tests import relative paths, so no dist is in the resolution and no rebuild
leg is required.

leg result
guard can never fire 32 failed / 43 passed — the announcement pins
guard always fires 33 failed / 42 passed — the "stays silent" pins
restored baseline 75 passed

The second leg is the one that matters beyond the usual: it proves the suite also
refuses a guard that just warns about everything, so the guard is measured to
DISCRIMINATE, not merely to fire.

Verification

Gate union run after the final commit, at adb3c3ace:

  • pnpm --filter '@object-ui/fields' type-check — clean (it runs
    tsconfig.test.json too, so the new test files are typed, not merely present)
  • pnpm exec vitest run packages/fields/ packages/plugin-detail/ packages/plugin-form/
    308 files, 3860 tests, all passed. The two consumer packages are included
    because NumberField's root changed from a bare input to a wrapped one.
  • check-control-bytes, check-changeset-presence, check-changeset-no-major,
    check-doc-links, check:doc-snippets (267/267 blocks judged, 0 failed),
    check:readme-exports, check:doc-types, check:doc-fences — all green.
  • Also green separately: check:entry-guard, check:action-forward-parity,
    check:designer-field-key-parity, check:element-data-source-declaration,
    check:icon-record-names, check:phantom-deps, check:self-import,
    check:side-effects-array, check:spec-symbols, check:i18n-drift,
    check:i18n-dead-keys, check:skills-paths, check:shell-escape-residue,
    check:node-esm-load, check:vi-mock-specifiers, check:lint-coverage.
  • check:eager-closure and check:sdui-registration-pins exit 2 —
    PREREQUISITE NOT MET, they need an apps/console build this container has no
    reason to produce. Recorded as NOT MEASURED, not as green.

Lint — declared narrowing. Ran eslint . over packages/fields (201 files
judged, read from --format json) rather than the whole repo. The narrowing
provably excludes nothing relevant: eslint's own config declares its population
as **/*.{ts,tsx}, so the doc and changeset changes are outside it entirely, and
the config sets no projectService/project, so type-aware linting is off and
this diff cannot move the verdict on any file it did not touch. All 14 changed
.ts/.tsx files are inside packages/fields. Result: 0 errors, and the
new suite carries 0 warnings after its any casts were collapsed into one
structural type.

Out of scope, filed rather than folded in

  • CurrencyField drops a host onBlur. It writes onBlur after the
    toDomProps spread, so a host handler is overridden — pre-existing, and left
    exactly as it was. No host in this repo passes one today (the data-table inline
    editor uses a document-level pointerdown listener), so it is latent; repairing
    it would be an unmeasured behaviour change outside this ruling.
  • NumberField never reads the published error slot. 字段 widget 的错误提示键:spec 声明 error,objectui 渲染 errorMessage(declared ≠ enforced) #3222 gave the other
    numeric widgets one and this widget none, so a form validation failure cannot
    mark it invalid. This PR writes aria-invalid on it only when refused,
    precisely so it does not stamp "false" over the value FormControl's Slot
    hands down.
  • The oracle divergence (existing unit tests drive strings no browser can
    deliver) stays with the PM seat's own card, per the dispatch order. This PR
    touches that suite only to correct the badInput claim and to move the shared
    readings into one module; it does not widen it.

Generated by Claude Code

claude added 2 commits August 29, 2026 15:05
A `type="number"` widget could DISPLAY one value and store another with
nothing said. Typing `1e` leaves Chromium visibly showing `1e` while
`.value` reads the empty string, so the widget emitted `null`,
`aria-invalid` stayed `"false"`, and no diagnostic was drawn.

Announce when `e.target.validity.badInput` is true — the platform's own
predicate, not a renderer-side dialect — across CurrencyField,
PercentField, NumberField and GeolocationField as one class, reusing
objectui#6716's refusal shape. Adds the blur arm the paste route needs:
pasting `1e` into an empty box never moves `.value` off `''`, so React
suppresses the change event entirely.

The guard ANNOUNCES; it deliberately does not refuse. Refusing would
leave `props.value` unchanged and React's `updateInput` would write it
back over the raw text the diagnostic points at (measured in Chromium
and read out of React 19.2.8's own source).

Part of #6780

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
The binding second half of #6780's ruling. A control that warns about
`1e` but silently stores `1.23` for a pasted `1.2.3` teaches users that
no warning means the value is right, so the asymmetry is documented
rather than left implicit.

The canonical explanation is the fields guide; the currency, percent and
number pages carry a short pointer with their own numbers, and the
package README states it for consumers rendering these widgets directly.

Also collapses the new suite's `any` casts into one structural widget
type (AGENTS.md #6), and adds the changeset.

Part of #6780

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, 45 chunks) 3174.5 KB 3222.7 KB
Main entry chunk (gzip) 148.2 KB 350 KB
Entry file index-2Xs8yzuP.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.75KB 116.33KB
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) 242.76KB 61.32KB
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.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.46KB 62.46KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.03KB 32.64KB
plugin-gantt (index.js) 165.23KB 40.37KB
plugin-grid (index.js) 201.57KB 54.55KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 28.95KB 8.33KB
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) 76.75KB 25.49KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.40KB 1.20KB
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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests labels Aug 29, 2026
os-sales pushed a commit that referenced this pull request Aug 29, 2026
Both said `buildValidationRules` "still has no `location` branch". This
branch gives it one, so shipping that prose in the same package would
leave a contradiction for the next reader — the failure mode the
docblock exists to prevent.

Each sentence was a compound claim and only half of it is falsified:

  - "still has no `location` branch"      -> false as of this branch
  - "this card does not give it one"      -> still true (#6716 / the
    refusal-diagnostic card did not add it; objectui#6744 did)

So the true half is kept verbatim and only the false half is corrected,
with a pointer to the card that added the branch and a note that it
serves the STORED case and never these refusal arms — which is the
property the surrounding paragraph is actually about, and which this
branch confirms rather than contradicts.

Prose only: no assertion, no behaviour, no export, no changeset. Neither
file is in PR #6801's file list (verified against its 14 files).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@os-sales
os-sales marked this pull request as ready for review August 29, 2026 22:15
@os-sam
os-sam added this pull request to the merge queue Aug 30, 2026
Merged via the queue into main with commit 7b90231 Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6780-badinput-announce branch August 30, 2026 02:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Decision] A type="number" widget can display one value and store another with nothing said — announce it, and at what scope?

2 participants