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
formatPercentBody renders 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
formatPercent suite 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.ts becomes 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
Found and measured while implementing objectui#4576 (PR #4589). Not fixed there:
packages/fields'formatPercentimplementation is outside that card's declared surface, and this is a different claim from the one #4576 was making.The defect
formatPercentBodyrenders throughIntlwithstyle: 'percent'. That style expects a FRACTION, so a value already in percentage points is divided by 100 forIntlto multiply it straight back:The round trip is not value-preserving.
displayValue / 100is 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:
formatPercent1.00%1.01%1.02%1.03%1.4%1.5%1.05%1.06%The mechanism, shown:
0.175as a double is0.174999999999999988..., butIntlformats from the SHORTEST decimal representation, which is0.175, and rounds that half-up to0.18. Divide it by 100 first and the shortest representation of the result is0.0017499999999999998, which percent-scales to0.17499999999999998and rounds to0.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_INTEGERpercentage points render9,007,199,254,740,990%, and 1e23 renders99,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
formatPercentgained 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.formatMeasurein@object-ui/corehad the same choice to make in objectui#4576 and took the other route: it formats the percentage points DIRECTLY withIntl'sstyle: 'unit'/unit: 'percent'/unitDisplay: 'narrow', which was measured to produce a byte-identical percent affix tostyle: 'percent'across all 171 locale tags tested while moving 0 of those 1,200,013 forms. It is exposed on the movedDisplayNumberFormatOptionsasstyle: 'percentPoints'.So the fix is available and already in the tree: point
formatPercentBodyatstyle: '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:
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.tsso 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.formatPercent's locale convention output is byte-identical for every locale (the affix parity is measured; this must not become a convention change).formatPercentsuite stays green apart from any pin that asserts a tie, declared if so.percent-cell-vs-measure-4576.test.tsbecomes an agreement pin.Blocked-by: nothing — PR #4589 lands the
percentPointsoption this needs, so this is actionable once that merges.Generated by Claude Code