Skip to content

[number-field] Add raw input value control - #5412

Draft
michaldudak wants to merge 7 commits into
mui:masterfrom
michaldudak:claude/numberfield-raw-value-control-8d1aa3
Draft

[number-field] Add raw input value control#5412
michaldudak wants to merge 7 commits into
mui:masterfrom
michaldudak:claude/numberfield-raw-value-control-8d1aa3

Conversation

@michaldudak

@michaldudak michaldudak commented Aug 4, 2026

Copy link
Copy Markdown
Member

Problem

value holds a parsed number, so it can't represent text that isn't a number yet. Typing -1.5 passes through - and -1., neither of which parses, and both leave value untouched. Those are legal states internally — typing - already leaves the text as - with value at null — but there was no way to reach them programmatically.

Solution

Adds inputValue, defaultInputValue, and onInputValueChange to NumberField.Root, mirroring the trio Combobox and Autocomplete already use.

const [inputValue, setInputValue] = React.useState('');

<NumberField.Root min={-100} inputValue={inputValue} onInputValueChange={setInputValue}>

setInputValue('-') now sticks, and typing continues from it.

The raw text moves from React.useState to useControlled. onInputValueChange reports the text two ways, which the docs describe separately:

  • Verbatim for input-change, input-clear, and input-paste — exactly what the user produced, unformatted.
  • Formatted for input-blur, the step reasons, and none (an external value/locale/format change, or blur reconciling leftover text).

Mirroring every call back into inputValue reproduces the uncontrolled behavior. Declining a formatted proposal keeps your own text; declining an input-change call vetoes the keystroke outright.

Text authorship

Supporting a controlled raw value meant the component had to answer "is the text currently a projection of value, or did the user author it?" precisely. That was already tracked, as allowInputSyncRef — ambient mutable state written by five files before the operation that justified it and read by ten sites. Two rounds of review found bugs caused by that shape: writers set it speculatively and had to undo on failure, and readers couldn't observe transitions, so the formatting sync carried a second shadow ref to diff the flag across renders.

It's now a property of each write:

type TextSource = 'value' | 'user';

setInputValue(next, details, source)      // source applies only when the write lands
setValue(next, details, { projectText })  // scoped to the call, not set beforehand

A vetoed keystroke never records a source, so there is nothing to undo. setInputValue is the only writer; the context exposes a read-only isTextUserAuthored(). allowInputSyncRef, previousAllowInputSyncRef, and the synthesized syncResumed transition are gone.

Behavior changes

Three, all deliberate:

  1. Blur reconciles unparseable text. Blur used to return early when the text couldn't parse, leaving cleanup to whatever re-render happened next — so a leftover - cleared inside Field.Root and persisted outside it. Blur now reconciles it itself, and the result no longer depends on an unrelated render. This is what allows the shadow ref to be deleted.

  2. Text that overflows to Infinity reconciles on blur too, rather than staying displayed against a null value. Same category as (1); previously also render-dependent. Note that native <input type="number"> keeps the raw text here, so this one is arguable — flagging it explicitly for review.

  3. Canceling a text update cancels the whole keystroke. The numeric value is derived from the text just refused, so applying it left the text and value disagreeing — and blur resolved that by reading the text and clearing a value the consumer had accepted. Per-channel independence still holds for value-driven syncs (stepping, scrubbing).

Testing

24 new tests. Each guard that survives in the final design was verified load-bearing by removing it and watching the suite fail.

Full jsdom suite (7529) green, 416 jsdom / 446 chromium NumberField tests, forced tsgo -b clean, plus eslint, stylelint, prettier, valelint, and an idempotent docs:validate.

🤖 Generated with Claude Code

`value` holds a parsed number, so it cannot represent text that is not a
number yet. Typing `-1.5` passes through `-` and `-1.`, neither of which
parses, leaving no way to drive the field through those states
programmatically.

Add `inputValue`, `defaultInputValue`, and `onInputValueChange` to
`NumberField.Root`, mirroring the trio Combobox and Autocomplete already
use. The raw text moves from internal state to `useControlled`, and every
internal write routes through a setter that reports the change with the
reason that caused it.

The formatting sync effect runs on every render, so it now only pushes
value-derived text when the formatted text actually changed, or when the
input sync resumes after typing ends. Without the former, a controlled
`inputValue` parked on `-` is overwritten on the next render; without the
latter, stale unparseable text is no longer reset after blur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown

commit: f9d6164

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 4, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+1.09KB(+0.24%) 🔺+377B(+0.26%)

Details of bundle changes

Performance

Total duration: 1,167.70 ms -118.27 ms(-9.2%) | Renders: 78 (+0) | Paint: 1,838.04 ms -209.43 ms(-10.2%)

No significant changes — details


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

@netlify

netlify Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit f9d6164
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a731c12645ba90008253fed
😎 Deploy Preview https://deploy-preview-5412--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.

@michaldudak michaldudak added component: number field Changes related to the number field component. type: new feature Expand the scope of the product to solve a new problem. labels Aug 4, 2026
michaldudak and others added 6 commits August 4, 2026 11:06
Regenerate the components index for the new Examples section, and use a
non-breaking space in the Base UI brand name as `MUI.MuiBrandName`
requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scope the write-dedupe memory to the event that caused it. The previous
mirror advanced unconditionally, so a proposal a controlled `inputValue`
owner ignored left it holding a string the state never took, swallowing
the next genuine change for that same string. This is reachable through
the pattern the docs teach: decline the `none` proposals to keep your own
text, and the next keystroke matching the declined string is dropped.

Cancelling a text update now cancels the whole keystroke. The numeric
value is derived from the text that was just refused, so applying it left
the text and value disagreeing, and blur resolved that by reading the
text and clearing a value the consumer had accepted. A refused edit also
restores the input-sync flag, since there is no manual edit to track.

Report the text before the value when pasting, matching the typing path,
so consumers mirroring both callbacks see one consistent order.

Correct the `inputValue` state description, which still promised a
formatted string, and add the post-blur resync to the documented meaning
of the `none` reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forcing the flag back to `true` on a veto only held when the refused
keystroke was the first edit. Once earlier keystrokes were accepted the
text has already diverged from the formatted value, so declaring the
field pristine let the formatting sync reclaim in-progress text and made
blur and keyboard stepping read `value` instead of what was typed.
Snapshot the flag and restore it instead. Every existing cancellation
test vetoed from a pristine field, which is why none of them caught it.

Record the dedupe entry even when a write is canceled, so a refused blur
normalization isn't re-proposed by the second write in the same event.

Compute the formatted value after the input-sync guard, restoring the
original behavior where typing pays no formatting cost. Only the
input-sync flag needs unconditional tracking: the render that resumes
syncing passes on `syncResumed` without consulting the formatted value,
and every other render reaching that point refreshed it previously.

Separate the two reporting channels in the docs — typing, clearing, and
pasting carry the raw string, while blur, stepping, and `none` propose
formatted text — and use Vitest's `toEqual` per AGENTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`allowInputSyncRef` was ambient mutable state, written by five files
before the operation that justified it and read by ten sites. That shape
produced both defects found in review: writers had to set it
speculatively and undo on failure, and readers couldn't observe
transitions, so the formatting sync needed a second shadow ref to diff
the flag across renders.

Replace it with authorship recorded as a property of each text write.
`setInputValue` takes the source and applies it only when the write
lands, so a vetoed keystroke has nothing to undo. `setValue` takes
`projectText`, so reconciling the text is a scoped argument at the call
site rather than a flag left set beforehand. Nothing outside the root
mutates authorship; the context exposes a read-only accessor.

`previousAllowInputSyncRef` and the synthesized `syncResumed` transition
are gone. They existed only because blur returned early when the text
couldn't parse, leaving cleanup to whatever re-render happened next —
which is why a leftover `-` cleared inside `Field.Root` and persisted
outside it. Blur now reconciles unparseable text itself, so the behavior
no longer depends on an unrelated render.

Text that overflows to `Infinity` when parsed reconciles on blur for the
same reason, rather than being left displayed against a null value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The formatting sync only re-derived the text when the formatted value
changed, so text `setValue` had optimistically projected was never
repaired once a controlled `value` owner refused the change: the field
kept displaying a number it never stored, and no later render could
correct it. Track the last text the component proposed and re-derive
whenever the text on screen is that text, leaving text a controlled
`inputValue` owner supplied alone.

Blur also reported the clear a second time when the field was already
empty, because the typed text still counted as user-authored while
`setValue` ran. It now clears only what there is to clear, and hands the
text back to `value` as a separate step so an already-empty field still
follows later external values.

A deduped write matching a refused proposal transferred authorship back
to `value`, which let the formatting sync write the very string the
consumer had canceled. Authorship now transfers only for proposals that
landed.

Docs: `'none'` no longer covers the leftover-text reset, which blur
reconciles under `'input-blur'`, and a controlled `inputValue` owner has
to mirror or cancel every call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects found while reviewing the raw input value control:

- A refused blur left the text marked as the user's forever, so the field
  stopped following `value`, `locale`, and `format` for the rest of its life.
  The base version reset `allowInputSyncRef` unconditionally at the top of
  `onBlur`, before the early returns; nothing replaced that on the canceled
  paths. Keeping the edit on screen and continuing to track `value` are now
  separate: `releaseTextOwnership` hands the text back without touching it,
  recording what the field would have shown so the sync effect leaves the text
  alone until one of its inputs really changes.

- Pasting an intermediate string (`-`, `.`) was a no-op, because the paste
  handler only wrote text that parsed. Typing the same characters works, so
  paste was the one route that could not reach a state `inputValue` is
  documented to hold. The character filter is now shared by both routes, and
  only the numeric update stays gated on parsing.

- Blur reconciling unparseable text settled the field visually but returned
  before `validation.commit` and `onValueCommitted`, so a pending commit leaked
  into whichever unrelated blur came next.

Also corrects the `defaultInputValue` fallback in its JSDoc and documents that
blur reconciles intermediate text however it got there, including text set
programmatically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michaldudak

Copy link
Copy Markdown
Member Author

Alternative implementation using an imperative actionsRef.setInputValue action instead of the prop trio: #5421. The two are mutually exclusive — only one should land.

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

Labels

component: number field Changes related to the number field component. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant