Repository navigation
[fields] formatPercent rounds a tie the wrong way: a stored 1.005 renders 1.00% where half-up is 1.01% #4590
Description
Found and measured while implementing objectui#4576 (PR #4589). Not fixed there: packages/fields' formatPercent implementation is outside that card's declared surface, and this is a different claim from the one #4576 was making.
The defect
formatPercentBody renders through Intl with style: 'percent'. That style expects a FRACTION, so a value already in percentage points is divided by 100 for Intl to multiply it straight back:
return formatDisplayNumber(displayValue / 100, {
locale, style: 'percent',
minimumFractionDigits: precision, maximumFractionDigits: precision,
});
The round trip is not value-preserving. displayValue / 100 is a double division, and for a value sitting exactly on a rounding tie it lands just BELOW the tie, so the digit rounds down instead of up.
Measured, en-US, node v22.22.2 / ICU 78.2:
| stored value | precision | formatPercent |
half-up on the authored decimal |
|---|---|---|---|
| 1.005 | 2 | 1.00% |
1.01% |
| 1.025 | 2 | 1.02% |
1.03% |
| 1.45 | 1 | 1.4% |
1.5% |
| 1.055 | 2 | 1.05% |
1.06% |
The mechanism, shown: 0.175 as a double is 0.174999999999999988..., but Intl formats from the SHORTEST decimal representation, which is 0.175, and rounds that half-up to 0.18. Divide it by 100 first and the shortest representation of the result is 0.0017499999999999998, which percent-scales to 0.17499999999999998 and rounds to 0.17. The division, not the rounding, is what loses the digit.
This is locale-independent — it is a numeral defect, not a convention one.
Scale
On a 0.005-step grid of display magnitudes from 1 to 200, 2,999 value/precision combinations render a different last digit than half-up on the authored decimal. Across a wider tie-dense grid (0.005 steps to 2,000, precisions 0/1/2) it is 27,581 of 1,200,013 forms. Every case is a last-digit-off-by-one, which is the failure mode least likely to be noticed and most likely to be trusted.
The same round trip also loses digits at the top of the double range: MAX_SAFE_INTEGER percentage points render 9,007,199,254,740,990%, and 1e23 renders 99,999,999,999,999,990,000,000%. objectui#4577 measured that end of it (24 of its 32,760 combinations) and declined to adopt the style for it; what was not known then is that the same artefact reaches down to ordinary magnitudes.
Where it came from, and why it is filed rather than fixed
formatPercent gained this implementation in objectui#4553 / PR #4565, which fixed a real and worse defect (it had rendered in NO locale — an ASCII decimal mark and never a grouping separator). The tie behaviour was not part of that card's measurements. Nothing here suggests #4565 was wrong to land; this is the residue.
formatMeasure in @object-ui/core had the same choice to make in objectui#4576 and took the other route: it formats the percentage points DIRECTLY with Intl's style: 'unit' / unit: 'percent' / unitDisplay: 'narrow', which was measured to produce a byte-identical percent affix to style: 'percent' across all 171 locale tags tested while moving 0 of those 1,200,013 forms. It is exposed on the moved DisplayNumberFormatOptions as style: 'percentPoints'.
So the fix is available and already in the tree: point formatPercentBody at style: 'percentPoints' and drop the / 100.
The consequence today
The two surfaces #4576 was about now agree on the percent CONVENTION but still disagree at ties, in opposite directions:
formatPercent(1.005, 2, 'en-US') -> "1.00%" (list cell)
formatMeasure(1.005, '0.00%', undefined, 'whole', 'en-US') -> "1.01%" (dashboard measure)
The MEASURE is the faithful one here and the CELL is the artefact, which is worth stating plainly because "the cell is the reference" is the natural assumption. This pair is pinned as an explicitly NOT-a-defect case in packages/fields/src/__tests__/percent-cell-vs-measure-4576.test.ts so it is recorded rather than rediscovered; that pin moves when this is fixed.
Acceptance criteria
formatPercentBodyrenders percentage points without a divide-and-remultiply round trip.- The tie cases above render half-up on the authored decimal.
formatPercent's locale convention output is byte-identical for every locale (the affix parity is measured; this must not become a convention change).- objectui#4565's
formatPercentsuite stays green apart from any pin that asserts a tie, declared if so. - The NOT-a-defect pin in
percent-cell-vs-measure-4576.test.tsbecomes an agreement pin.
Blocked-by: nothing — PR #4589 lands the percentPoints option this needs, so this is actionable once that merges.
Generated by Claude Code
Activity
yinlianghui commentedon Aug 13, 2026
CLAIM — session
session_017Qqyix2QcnpUC9XeYVDzx3, branchclaude/issue-4590-percent-tie-halfup. Unblocked: PR #4589 landed on main (92250d648), sostyle: 'percentPoints'is available.PM ruling (delegated, veto window open): implement exactly this issue's prescription —
formatPercentBodyrenders throughstyle: 'percentPoints'and drops the/ 100round trip. Surface ispackages/fieldsonly (src/index.tsx+ its tests): the cross-surface pin inpercent-cell-vs-measure-4576.test.tsflips from NOT-a-defect to an agreement pin; #4565's suite stays green apart from individually-declared tie-pin moves; convention affix must be byte-identical per locale (re-verify affix parity on this surface — a convention move here is a STOP, this card is numeral-only). Extreme magnitudes (MAX_SAFE_INTEGER, 1e23) become digit-exact — declared moving pins. Grading:'@object-ui/fields': minoron the published-behavior-move precedent line (#4495 / #4271 / #4479), expect a byte-identical.d.ts(no signature change) — measure and report both. Never major.Mutual exclusion: #4580's in-flight seat owns
packages/types+packages/coretype declarations; #4592's ownsscripts/**. This card touches neither.Generated by Claude Code
Generated by Claude Code
yinlianghui commentedon Aug 13, 2026
PM step-7 复核: ACCEPT. PR #4595 matches the ruling exactly — fields-only 4-file surface, the prescribed
percentPointsmechanism with the/ 100round trip removed, and the convention half proven unmoved two ways (10-locale byte-exact pins including tr's prefix and ar's U+066A+U+061C, plus a standing affix-parity assertion against Intl's ownstyle:'percent'so future drift fails in-suite). The methodology correction in the affix-parity harness (Intl part-labelpercentSignvsunitnaming, normalized before comparing rendered bytes) is noted and correct — rendered output is the contract, not part labels. The agreement pin now asserts the two surfaces against each other, which is the durable form. Changeset'@object-ui/fields': minoron the behavior-move precedent with the byte-identical.d.tsstated alongside — both facts, graded on the right one. #4596 (stale comments in core citing the old route) is correctly observation-class and correctly not fixed here.Flipping ready and arming auto-merge now.
Generated by Claude Code
Generated by Claude Code
fix(fields): formatPercent renders points directly — ties round half-…
docs(core): name the grid and surface behind the 27,581 of 1,200,013 …
docs(core): name the grid and surface behind the 27,581 of 1,200,013 …