[number field] Respect rounding mode on blur#4804
Conversation
commit: |
Bundle size
PerformanceTotal duration: 1,026.10 ms +5.62 ms(+0.6%) | Renders: 50 (+0) | Paint: 1,563.53 ms +14.50 ms(+0.9%) No significant changes. Check out the code infra dashboard for more information about this PR. |
bc5b17c to
11213c4
Compare
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
mj12albert
left a comment
There was a problem hiding this comment.
Codex finding:
style: 'percent'still rounds the raw fraction, not the displayed percent.
Invalidate.ts:37, the new Intl path builds a plain decimal formatter. But percent inputs are parsed as fractions inparse.ts:211, and blur commits viaNumberFieldInput.tsx:191. Forformat={{ style: 'percent', maximumFractionDigits: 2, roundingMode: 'floor' }},1.239%parses to0.01239,commits as0.01,and displays as1%; Intl would display the parsed value as1.23%. Consider scaling percent values before applying the helper’s fraction rounding.
bfa245c to
b798d64
Compare
b798d64 to
ca1af48
Compare
flaviendelangle
left a comment
There was a problem hiding this comment.
PR Review Summary — mui/base-ui #4804
[number field] Respect rounding mode on blur — 4 files, +191/-21, fixes #4803
The PR correctly fixes the core bug (toFixed ignoring Intl roundingMode) and the percent-scale issue. Tests are well-structured. However, the refactor introduces two critical silent failures and the type-design / behavior of getMaximumFractionDigits has real correctness concerns.
Critical Issues (must fix before merge)
-
NaN leak via
Number(Intl.NumberFormat.format(Infinity))—packages/react/src/number-field/utils/validate.ts:49
Number.isFinite(value)guards the input, butvalueToRound = value * 100can overflow toInfinity; en-US formats that as"∞",Number(...)returnsNaN, whichonValueChangesilently emits. The pre-PR code returned a finite-or-Infinity number — this PR turns that into a NaN leak. -
Uncaught
RangeErroron invalidroundingMode/roundingIncrement—packages/react/src/number-field/utils/validate.ts:40-49
A user passing e.g.roundingIncrement: 3(not in Intl's allowed set) throws on every blur. The oldtoFixedpath silently ignored those fields. No try/catch, noBase UI Plus:warning per AGENTS.md guidelines. -
getMaximumFractionDigitsreturns wrong digit count when onlyminimumFractionDigitsis set —packages/react/src/number-field/utils/validate.ts:13-22
{ minimumFractionDigits: 2 }resolves to max=3 (Intl picksmax(min, 3)), so user typing1.2399rounds to1.240, not1.24. Worse: pre-PR code skipped rounding entirely in this branch (it requiredtypeof maxFrac === 'number'), so the PR introduces a behavior change beyond what the description claims. The existing percent min-only test only passes becausefloor+ that input gives the same result at 2 or 3 digits.
Important Issues (should fix)
-
getMaximumFractionDigitsconditional is misleading —packages/react/src/number-field/utils/validate.ts:14-19
Theformat?.minimumFractionDigits == null ? undefined : formatbranch partially fixes currency/percent defaults but only for one input shape. Either always passformator always passundefined— the middle ground silently introduces inconsistency. -
roundingMode: stringandroundingIncrement: numberdiscard standard-library invariants —packages/react/src/number-field/utils/validate.ts:6-9
TS 6.0.3'slib.es2023.intl.d.tsalready encodes precise literal unions (9 mode values, 15 increments). The local type alias is strictly weaker than what the standard provides. The real fix: bumptsconfig.base.jsonlibfrom["es2022", "dom"]to["es2023", "dom"]and delete the alias entirely. Node 22+ and all evergreen browsers support these at runtime. -
Rounding-mode test coverage only exercises
'floor'
At minimum add'halfEven'(1.235 → 1.24,1.245 → 1.24, the canonical proof the path goes throughIntl.NumberFormat) and a negative-value'trunc'/'floor'divergence test (e.g.-1.239). Without these, a future regression totoFixedsemantics could pass every new test. -
roundingIncrementblur integration test asserts display only, not committed value —packages/react/src/number-field/input/NumberFieldInput.test.tsx:643-667
Add one assertion ononValueChange.mock.lastCall?.[0]. -
style: 'currency'blur path is untested
Currency does not get the*100scale; one blur test would lock that contract down.
Suggestions
-
Comment wording invites rot —
packages/react/src/number-field/utils/validate.ts:6
"configured Intl types … yet" obscures thattsconfig.base.jsonlib: ["es2022"]is the real knob. Either bumplib(recommended) or reword to name the gating setting so it's clear when to delete the alias. -
Missing comments on the two trickiest lines
isPercentWithExplicitPrecision+ scale=100 trick, and themin-only ? undefined : formatconditional both deserve a one-line WHY. -
Untested edge cases
Negative numbers, NaN early-return, currency-style, and step+roundingMode interaction. -
Redundant tests
packages/react/src/number-field/utils/validate.test.ts:37-51packs two cases in oneit(); the percent min-only path is covered twice (unit and integration).
Strengths
- Correct percent-scale fix;
style:'unit', unit:'percent'correctly excluded from*100scaling and tested. useGrouping: false+'en-US'formatter guarantees safeNumber(...)parse (no scientific notation instandardnotation).renderControlledNumberFieldhelper andit.eachmatrix for fr-FR / ar-EG locales are high-signal, DAMP-readable tests.- Commit messages follow
[number field] …scope convention. - Import path
NumberFieldInput.tsx → ../utils/validateis appropriate; no layering concern.
Recommended Action
- Fix the critical issues first — guard the
Number(formatter.format(...))result withNumber.isFinite, wrap formatter construction in try/catch with aBase UI Plus:dev warning, and decide themin-only → resolved-maxsemantics deliberately (probably: useformat.minimumFractionDigitsdirectly). - Bump tsconfig
libtoes2023and delete the type alias. - Add
halfEvenand negative-value rounding tests, plus anonValueChangeassertion for theroundingIncrementblur test. - Reword or remove the misleading "configured Intl types" comment.
- Re-run
/pr-review-toolkit:review-prafter changes.
Some points are invalid, addressed the valid ones:
|
flaviendelangle
left a comment
There was a problem hiding this comment.
PR #4804 Re-Review — Second Pass
Repo: mui/base-ui
PR: #4804 — [number field] Respect rounding mode on blur
HEAD: db7a23065 ([number field] Harden rounding edge cases)
Fixes: #4803
The author addressed most prior findings well, but the overflow "fix" introduces a new high-severity divergence between onValueChange and onValueCommitted. I'm also retracting one of my previous critical findings — the author's rebuttal was correct.
What's resolved (acknowledged)
- Overflow → NaN leak (prior C1): the
Number.isFinite(valueToRound)guard atvalidate.ts:36-38does what it says. - RangeError rebuttal (prior C2) — sound:
parse.ts:74,NumberFieldRoot.tsx:147,269,314,699already callgetFormatterwith the user'sformaton render/keystroke. An invalidroundingIncrement: 3crashes on mount, not on blur. No new surface added. - Test gaps:
halfEven(both directions), negative-value mode divergence, currency blur, andonValueChangeassertion forroundingIncrementare all properly closed. - Comment: factually accurate now (
tsconfig.base.json:5confirmslib: ["es2022", "dom"]).
Retracted from prior review
- Min-only-precision (prior C3) — withdraw. With
{minimumFractionDigits: 2},getMaximumFractionDigitsresolvesdigits = 3(Intl decimal default), but Intl display also usesmax=3for the same options. Committed numeric value matches displayed text. Author was correct.
NEW high-severity finding
Infinity leaks past the overflow guard into onValueCommitted and the visible input — NumberFieldInput.tsx:185-218
On blur with style: 'percent' + a finite-but-huge value (Number.MAX_VALUE), removeFloatingPointErrors now returns Infinity. Downstream:
setValue(Infinity, …)→toValidatedNumber→clamp(Infinity, MIN_SAFE_INTEGER, MAX_SAFE_INTEGER)=MAX_SAFE_INTEGER→onValueChangefires withMAX_SAFE_INTEGER.onValueCommitted(committed, …)is NOT re-validated (forwarded raw atNumberFieldRoot.tsx:129-134) → fires withInfinity. Two listeners observe different values.formatNumber(Infinity, locale, {style:'percent'})returns"∞%". The input visibly displays"∞%"while the form state holdsMAX_SAFE_INTEGER.parseNumber("∞%", …)returnsnull→ next blur is a no-op. User is stuck.
The previous overflow fix moved the silent failure one level up. Recommendation: in the overflow branch at validate.ts:36-38, return a sentinel (null or the pre-blur value) that the blur path treats as "don't commit", rather than letting Infinity propagate.
Medium
- Subnormal underflow to 0 —
validate.ts:41.(Number.MIN_VALUE * 100).toFixed(2) === "0.00"→ a non-zero input silently becomes 0. Same class as the original NaN bug; exotic in practice. - Currency +
minimumFractionDigitsis now an undocumented behavior shift. Pre-PR,getFormatter('en-US')resolved max=3 (decimal default). Post-PR, the conditional atvalidate.ts:17forwards the format, so currency resolves max=2. More correct — but a1.239input with{style:'currency', currency:'USD', minimumFractionDigits:1}now rounds to1.24instead of1.239. Worth a one-line changelog note.
Test gaps (low–medium)
step×roundingModeinteraction untested (validate.ts:115,125,128).clamp×roundingModeinteraction untested.- Negative-value integration test (keyboard type
-1.239, assertonValueChange) untested. - No
ceil/halfExpandcoverage despite the PR title claiming general rounding-mode support.
Comments (nice-to-have)
validate.ts:6: tie alias lifetime to a deletion trigger — "Delete oncetsconfig.base.jsonlibincludes es2023."validate.ts:30-34: theisPercentWithExplicitPrecision+ scale=100 trick needs a one-liner pointing atparse.ts:211-215(percent stored as fraction; rounding must happen at display scale).validate.ts:17: the conditionalformat?.minimumFractionDigits == null ? undefined : formatreads like a typo; a one-line WHY (currency/percent default-max would otherwise clamp precision unexpectedly) would prevent a "fix" that breaks it.
Recommended action
- Address the
Infinityleak in the blur path (high). The cleanest fix is at the helper: don't returnInfinity— propagate a non-commit signal. - Add a step + roundingMode test to lock the composition.
- Add the three one-line comments at
validate.ts:6, :17, :30. - Note the currency-default behavior change in the changelog.
- Items 2–4 can ride; item 1 is a real bug.
Fixes #4803
Keep the committed value in sync with Intl rounding options when a number field rounds on blur.
Root cause
The blur commit path used
toFixed, so it rounded with the default mode even when the display formatter used options likeroundingMode: floor. Percent values also need to round at their displayed scale before converting back to the stored fraction.Changes
roundingModeorroundingIncrementis present, preserving the existingtoFixedpath otherwise.