Current behavior
parseNumber keeps only the last . and strips the rest (utils/parse.ts:190-193, "Mixed-locale safety"). That is deliberate — it lets a European-formatted paste like 1.234.567.89 resolve to 1234567.89 in a US-locale field, pinned by utils/parse.test.ts:193-204. But the same rule also accepts strings that are not a plausible number in any locale:
parseNumber('1.2.3'); // 12.3
When such text arrives as raw input, the field shows what was entered while value and the hidden <input type="number"> hold the normalized number. The two stay divergent until blur, so a form submitted in between sends a number the user never saw.
<NumberField.Root name="n" defaultValue={5} onValueChange={console.log} />
Drop, IME-compose, or autofill 1.2.3 into the input:
- visible input:
1.2.3
onValueChange: 12.3
- hidden submitted input:
12.3
Typing is unaffected — onKeyDown blocks a second decimal separator — so this only reaches non-keystroke text entry. It is also reachable through actionsRef.current.setInputValue('1.2.3') (API added in #5421), which routes through the same validation.
NumberFieldRoot.test.tsx:2606 covers the mixed-locale case but asserts only the resulting value, never the visible text, which is why the divergence has gone unnoticed.
Related leniencies from the same call, which may or may not be in scope:
parseNumber('1-2') → 1 (parseFloat stops at the first invalid character)
parseNumber('5-') → -5 (deliberate trailing-sign / accounting support — should stay)
Expected behavior
Text that resolves to a number the user did not enter should either be rejected, or normalized in the visible input, rather than leaving the display and the submitted value silently divergent.
Reproducible example
No CodeSandbox — reproduced directly against master in the repo's own test environment:
fireEvent.change(input, { target: { value: '1.2.3' } });
// visible "1.2.3", onValueChange(12.3), hidden input value "12.3"
Base UI version
master as of 1a2ca3c. Not a regression — the normalization predates #5421.
Which browser are you using?
All. Confirmed in jsdom and Chromium.
Which OS are you using?
All.
Additional context
Two places a fix could go:
- Tighten
parseNumber to reject implausible grouping, for example requiring interior dot-separated runs to be 3 digits. This must keep utils/parse.test.ts:193-204 green, which pins 1.234.567.89, and 1.234.567,89 in both fr-FR and en-US.
- Add a structural check next to
isValidInputString (utils/parse.ts:75) and call it from both raw-text entry points: NumberFieldInput's onChange, and the setInputValue action in NumberFieldRoot.tsx. This leaves parseNumber lenient for blur and paste normalization.
Option 2 looks lower risk. Either way the check belongs on both entry points — they share isValidInputString specifically so typed and imperative text cannot diverge.
Done when:
- Implausible text is either rejected or normalized in the visible input, never silently divergent from
value and the hidden input.
- Both the change-event path and the
actionsRef action are covered by tests.
- The existing mixed-locale cases in
utils/parse.test.ts still pass.
pnpm test:jsdom NumberField --no-watch and pnpm test:chromium NumberField --no-watch are green.
Current behavior
parseNumberkeeps only the last.and strips the rest (utils/parse.ts:190-193, "Mixed-locale safety"). That is deliberate — it lets a European-formatted paste like1.234.567.89resolve to1234567.89in a US-locale field, pinned byutils/parse.test.ts:193-204. But the same rule also accepts strings that are not a plausible number in any locale:When such text arrives as raw input, the field shows what was entered while
valueand the hidden<input type="number">hold the normalized number. The two stay divergent until blur, so a form submitted in between sends a number the user never saw.Drop, IME-compose, or autofill
1.2.3into the input:1.2.3onValueChange:12.312.3Typing is unaffected —
onKeyDownblocks a second decimal separator — so this only reaches non-keystroke text entry. It is also reachable throughactionsRef.current.setInputValue('1.2.3')(API added in #5421), which routes through the same validation.NumberFieldRoot.test.tsx:2606covers the mixed-locale case but asserts only the resulting value, never the visible text, which is why the divergence has gone unnoticed.Related leniencies from the same call, which may or may not be in scope:
parseNumber('1-2')→1(parseFloatstops at the first invalid character)parseNumber('5-')→-5(deliberate trailing-sign / accounting support — should stay)Expected behavior
Text that resolves to a number the user did not enter should either be rejected, or normalized in the visible input, rather than leaving the display and the submitted value silently divergent.
Reproducible example
No CodeSandbox — reproduced directly against
masterin the repo's own test environment:Base UI version
masteras of 1a2ca3c. Not a regression — the normalization predates #5421.Which browser are you using?
All. Confirmed in jsdom and Chromium.
Which OS are you using?
All.
Additional context
Two places a fix could go:
parseNumberto reject implausible grouping, for example requiring interior dot-separated runs to be 3 digits. This must keeputils/parse.test.ts:193-204green, which pins1.234.567.89, and1.234.567,89in bothfr-FRanden-US.isValidInputString(utils/parse.ts:75) and call it from both raw-text entry points:NumberFieldInput'sonChange, and thesetInputValueaction inNumberFieldRoot.tsx. This leavesparseNumberlenient for blur and paste normalization.Option 2 looks lower risk. Either way the check belongs on both entry points — they share
isValidInputStringspecifically so typed and imperative text cannot diverge.Done when:
valueand the hidden input.actionsRefaction are covered by tests.utils/parse.test.tsstill pass.pnpm test:jsdom NumberField --no-watchandpnpm test:chromium NumberField --no-watchare green.