[number-field] Add raw input value control - #5412
Draft
michaldudak wants to merge 7 commits into
Draft
Conversation
`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>
commit: |
Bundle size
PerformanceTotal 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. |
✅ Deploy Preview for base-ui ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
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>
Member
Author
|
Alternative implementation using an imperative |
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.
Problem
valueholds a parsed number, so it can't represent text that isn't a number yet. Typing-1.5passes through-and-1., neither of which parses, and both leavevalueuntouched. Those are legal states internally — typing-already leaves the text as-withvalueatnull— but there was no way to reach them programmatically.Solution
Adds
inputValue,defaultInputValue, andonInputValueChangetoNumberField.Root, mirroring the trio Combobox and Autocomplete already use.setInputValue('-')now sticks, and typing continues from it.The raw text moves from
React.useStatetouseControlled.onInputValueChangereports the text two ways, which the docs describe separately:input-change,input-clear, andinput-paste— exactly what the user produced, unformatted.input-blur, the step reasons, andnone(an externalvalue/locale/formatchange, or blur reconciling leftover text).Mirroring every call back into
inputValuereproduces the uncontrolled behavior. Declining a formatted proposal keeps your own text; declining aninput-changecall 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, asallowInputSyncRef— 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:
A vetoed keystroke never records a source, so there is nothing to undo.
setInputValueis the only writer; the context exposes a read-onlyisTextUserAuthored().allowInputSyncRef,previousAllowInputSyncRef, and the synthesizedsyncResumedtransition are gone.Behavior changes
Three, all deliberate:
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 insideField.Rootand 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.Text that overflows to
Infinityreconciles on blur too, rather than staying displayed against anullvalue. 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.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 -bclean, plus eslint, stylelint, prettier,valelint, and an idempotentdocs:validate.🤖 Generated with Claude Code