Skip to content

[fields] formatPercent rounds a tie the wrong way: a stored 1.005 renders 1.00% where half-up is 1.01% #4590

Description

@yinlianghui

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

Activity

  1. self-assigned this
    on Aug 13, 2026
  2. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    CLAIM — session session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4590-percent-tie-halfup. Unblocked: PR #4589 landed on main (92250d648), so style: 'percentPoints' is available.

    PM ruling (delegated, veto window open): implement exactly this issue's prescription — formatPercentBody renders through style: 'percentPoints' and drops the / 100 round trip. Surface is packages/fields only (src/index.tsx + its tests): the cross-surface pin in percent-cell-vs-measure-4576.test.ts flips 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': minor on 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/core type declarations; #4592's owns scripts/**. This card touches neither.


    Generated by Claude Code


    Generated by Claude Code

  3. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    PM step-7 复核: ACCEPT. PR #4595 matches the ruling exactly — fields-only 4-file surface, the prescribed percentPoints mechanism with the / 100 round 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 own style:'percent' so future drift fails in-suite). The methodology correction in the affix-parity harness (Intl part-label percentSign vs unit naming, 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': minor on the behavior-move precedent with the byte-identical .d.ts stated 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions