Skip to content

[core][fields] A percent renders as 1.234,5 % in a list cell and 1.234,5% as a dashboard measure — two percent conventions for one number #4576

Description

@yinlianghui

Found and measured while implementing objectui#4566. Filed rather than fixed there: changing it moves output in a direction that card's ruling explicitly excluded, so it needs its own decision.

The divergence

The two formatters build the percent sign differently:

Measured, de-DE, the same stored value:

surface output
list cell (formatPercent) 1.234,5 % (U+00A0 before the sign)
dashboard measure (formatMeasure) 1.234,5%

English is identical on both paths (1,234.5%), so this is invisible in an en session. Affects every locale whose percent convention has a space: de, fr, es, ar were all measured to differ; en and zh do not.

This directly contradicts percentDisplayValue's own doc comment, which says the shared scaling exists so that "a percent renders identically as a row value and as an aggregated metric — the two surfaces can never drift". The SCALING no longer drifts; the CONVENTION now does.

Why #4566 did not fix it

Two measured reasons, both worth keeping on the record:

  1. It would move English. Intl's style: 'percent' re-scales by 100, and the round trip loses precision at the top of the range: 100,000,000,000,000,000,000,000% becomes 99,999,999,999,999,990,000,000% in en-US (24 of 32,760 measured combinations move, all at 1e21 and MAX_SAFE_INTEGER). [core] formatMeasure renders every dashboard measure in the MACHINE locale — the last parallel implementation outside the display-locale channel #4566's ruling made en byte-identity the discriminator for a correct mapping, so this change could not ride along on it.
  2. It is a change of RENDERING CONVENTION, not of locale channel — a different claim from the one [core] formatMeasure renders every dashboard measure in the MACHINE locale — the last parallel implementation outside the display-locale channel #4566 was making, and it deserves to be judged on its own.

The decision this needs

Which convention is the correct one for a dashboard measure, given both surfaces show the same underlying number:

A is the direction the one-resolver rule points; B contradicts a ruling already made on #4553; C is only defensible if the two surfaces are judged never to be read side by side, which the dataset preview and the report renderer make doubtful.

Worth deciding together with objectui#4575, which threads the remaining formatMeasure consumers — if A wins it should land before those surfaces multiply the inconsistency.


Generated by Claude Code

Activity

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

    @yinlianghui
    CollaboratorAuthor

    CLAIM — session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4576-one-number-home. Dispatching a dev agent now. This card is ruled TOGETHER with PR #4577's open question 1 (the formatDisplayNumber down-move) — the split this card reports is the live symptom of the two-homes drift that question describes, so they are one fix, not two.

    PM ruling (delegated decision authority; maintainer veto window open — record objections here):

    1. The down-move lands first, in this PR (PR fix(core): dashboard measures follow the display locale (#4566) #4577's option B, adopted): formatDisplayNumber, shouldGroupDisplayNumber and DisplayNumberFormatOptions MOVE from @object-ui/i18n into @object-ui/core (core is the lower, React-free layer — the function is pure and has no business behind a React/i18next package); @object-ui/i18n RE-EXPORTS them under the same names so every existing import path (fields, components, plugin-dashboard) keeps compiling byte-compatible. i18n gains a dependency on core — measure that this creates no cycle (core must not import i18n anywhere; fix(core): dashboard measures follow the display locale (#4566) #4577 already verified core's dependency list).
    2. Then the percent split closes on the moved home: formatMeasure's percent path routes through the SAME percent policy formatPercent uses (Intl style:'percent' with its locale-correct NBSP), replacing the literal '%' suffix — restoring percentDisplayValue's doc promise that a percent 'can never drift' between a list cell and a measure. The measured cost is DECLARED, not hidden: Intl percent moves en at absurd magnitudes (fix(core): dashboard measures follow the display locale (#4566) #4577 measured 24/32,760 combinations, all ≥ ~1e20%). That en move is the fix's declared behavior change (the fix(fields): formatPercent groups and follows the display locale — the last tooltip channel (#4553) #4565 precedent — consistency wins; pin the new forms red-first, note the old forms in the changeset). Ordinary-magnitude en percent must stay byte-identical — pin the threshold.
    3. Pins: fix(fields): formatPercent groups and follows the display locale — the last tooltip channel (#4553) #4565's formatPercent pins untouched; fix(core): dashboard measures follow the display locale (#4566) #4577's formatMeasure en pins updated ONLY where the percent path moves (authorized pin moves, declared in pins_moved, with the ordinary-magnitude cases proven unmoved); [plugin-report][app-shell] Dataset measures still render in the MACHINE locale — the consumers #4566 could not reach #4575/fix(plugin-report): report and preview measures follow the display locale (#4575) #4582's consumer pins green; the re-export identity pinned (import from i18n === import from core, type-identical); integers-stay-String(v) and the other fix(core): dashboard measures follow the display locale (#4566) #4577 deliberate preservations untouched.
    4. Changesets: '@object-ui/core' MINOR (entry gains the moved exports + the percent behavior move); '@object-ui/i18n' per its measured .d.ts (a re-export swap — if declaration-identical, patch; if the declaration moved, minor with the fix(types): the DataSource contract carries deleteView's per-home outcomes (#4564) #4569 precedent's reasoning); consumers per their diffs. Never major.
    5. STOP conditions: a dependency cycle; any consumer that imports the moved symbols by a path the re-export cannot preserve; the percent unification measurably breaking an ordinary-magnitude en form.

    Mutual exclusion: ⛔ in flight: #4563 (apps/console), #4535 (types+ObjectGrid), #4550 (packages/react navigation). packages/core, packages/i18n, packages/fields are claimed by nobody — verify with a fresh fetch. #4577/#4565/#4582 are landed context; their test files move only under authorized pin moves.


    Generated by Claude Code


    Generated by Claude Code

  3. added a commit that references this issue on Aug 13, 2026
  4. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    PM step-7 复核: ACCEPT — and the stage-2 mechanism substitution is CONFIRMED (option A, style: 'percentPoints' as implemented in PR #4589).

    复核 checked: the 15-file diff matches the declared surface exactly (the only fields/app-shell touches are test files, both in the authorized pin-move class); changeset '@object-ui/core': minor + '@object-ui/i18n': minor follows the #4544 (entry-visible growth) and #4569 (declaration-moved) precedents; packages/i18n/src/utils/number-display.ts is an honest re-export with a reference-identity pin so a second copy cannot silently return; CI converged green on head 2e1b87ccc (18 success + 2 skipped, 0 failures); #4590 filed with measurements and acceptance criteria.

    Why A and not the ruling's named mechanism (B): the ruling's stop conditions outrank its named mechanism. The dev measured first — as instructed — and the measurement disproved the ruling's premise: the style: 'percent' divergence is rounding-tie-keyed, not magnitude-keyed (smallest mover 0.175; 27,581 of 1,200,013 ordinary-magnitude en forms), which is STOP condition 3 verbatim. Implementing B was therefore forbidden by the ruling itself. A delivers the ruling's stated goal exactly — byte-identical percent affix to style: 'percent' across all 171 locale tags tested — while moving zero numerals at any magnitude, so even the pre-authorized extreme-magnitude en cost is not incurred. And 'percent' (value is a fraction) vs 'percentPoints' (value is already in points) names the exact trap this family of cards keeps rediscovering, at the call site, in the type system. Not C: the defect was filed and its blast radius was growing.

    The remaining tie-level gap with formatPercent is deliberately NOT closed here: the measure is the faithful surface and the cell is the artefact, so the fix belongs at the artefact's home — that is #4590, actionable the moment this merges.

    Veto window open as with every delegated ruling: reverting to the literal mechanism (B) is a two-line swap plus the tie/extreme pins per the report, at the cost of re-incurring the en movement the original ruling forbade.

    Flipping PR #4589 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