Skip to content

fix(fields): formatPercent renders points directly — ties round half-up and extremes keep every digit (#4590) - #4595

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4590-percent-tie-halfup
Aug 13, 2026
Merged

fix(fields): formatPercent renders points directly — ties round half-up and extremes keep every digit (#4590)#4595
yinlianghui merged 1 commit into
mainfrom
claude/issue-4590-percent-tie-halfup

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4590

Mechanism

formatPercentBody rendered a value that is ALREADY in percentage POINTS through Intl's style: 'percent', which expects a FRACTION — so it divided by 100 for Intl to multiply straight back:

return formatDisplayNumber(displayValue / 100, {
  locale, style: 'percent',
  minimumFractionDigits: precision, maximumFractionDigits: precision,
});

That round trip is not value-preserving. Intl formats from the SHORTEST decimal representation of the double it is handed, and the quotient's is not the authored one: 1.005 is 1.005, but 1.005 / 100 is 0.010049999999999999, which percent-scales to 1.0049999999999999 and rounds DOWN. The division loses the digit, not the rounding — which is why it reproduced identically in every locale, Arabic-Indic digits included.

The body now renders the points directly, through the option #4576 / PR #4589 added for exactly this:

return formatDisplayNumber(displayValue, {
  locale, style: 'percentPoints',
  minimumFractionDigits: precision, maximumFractionDigits: precision,
});

Affix-parity re-verification, on THIS call shape

PR #4589 measured style: 'unit' / unit: 'percent' affix parity for formatMeasure's path across 171 locale tags. Re-measured here for formatPercent's own call shape, old route vs new, with the affix isolated from the numerals via Intl.formatToParts — leading and trailing non-digit parts, the group separator glyph, the decimal separator glyph, and whether grouping happened:

locales=10 values=18 precisions=4 combinations=720
CONVENTION diffs (STOP condition): 0
NUMERAL-only diffs (the fix):      130

130 = 13 per locale, the SAME 13 in every one of the ten — which is the locale-independence claim, measured rather than asserted. Locale set: en-US, de-DE, fr-FR, tr-TR, ar-EG, ja-JP, zh-CN, ru-RU, sv-SE, bn-IN.

One methodology note, because it initially read as 720 convention diffs: style: 'percent' labels the sign part percentSign while style: 'unit' labels the SAME glyph unit. That is an Intl API label, not a rendered convention — formatPercent returns a string and no caller ever sees the part type — so the two are normalized to one name in the skeleton. Every rendered byte is identical; only the part's type field differs.

Convention pins at 1234.5 p1, old route vs new route. Written in U+XXXX notation rather than pasted, because three of the characters involved (U+00A0, U+202F, U+061C) are INVISIBLE — a pasted table would look like it had ordinary spaces, and one of them would be undetectable entirely:

en-US: "1,234.5" "%"                                          identical=true
de-DE: "1.234,5" U+00A0 "%"                                   identical=true
fr-FR: "1" U+202F "234,5" U+00A0 "%"                          identical=true
tr-TR: "%" "1.234,5"                                          identical=true  (sign in FRONT)
ar-EG: U+0661 U+066C U+0662 U+0663 U+0664 U+066B U+0665 U+066A U+061C   identical=true
ja-JP: "1,234.5" "%"                                          identical=true
zh-CN: "1,234.5" "%"                                          identical=true
ru-RU: "1" U+00A0 "234,5" U+00A0 "%"                          identical=true
sv-SE: "1" U+00A0 "234,5" U+00A0 "%"                          identical=true
bn-IN: U+09E7 "," U+09E8 U+09E9 U+09EA "." U+09EB "%"         identical=true

Negatives, where sign position and sign glyph are also convention — all identical, including Turkish's minus outside the prefixed sign, Arabic's leading U+061C, and Swedish's U+2212 MINUS SIGN (not an ASCII hyphen):

en-US: "-45.5" "%"                                identical=true
de-DE: "-45,5" U+00A0 "%"                         identical=true
tr-TR: "-" "%" "45,5"                             identical=true
ar-EG: U+061C "-" U+0664 U+0665 U+066B U+0665 U+066A U+061C   identical=true
sv-SE: U+2212 "45,5" U+00A0 "%"                   identical=true

Red-first

Predictions were written into the test file headers before the run. The red run had the new expectations present and the fix absent:

 ❯ packages/fields/src/__tests__/percent-tie-halfup-4590.test.ts (27 tests | 8 failed)
     × formatPercent(1.005, 2) is 1.01% (was 1.00%)
     × formatPercent(1.025, 2) is 1.03% (was 1.02%)
     × formatPercent(1.45, 1) is 1.5% (was 1.4%)
     × formatPercent(1.055, 2) is 1.06% (was 1.05%)
     × carries across the grouping boundary: 99999.995 to 2 decimals
     × the same ties move in every locale — this is a numeral defect
     × MAX_SAFE_INTEGER keeps its last digit
     × 1e23 renders as 1e23
 ❯ packages/fields/src/__tests__/percent-cell-vs-measure-4576.test.ts (12 tests | 1 failed)
     × AGREEMENT pin — the two now round ties the same way (was NOT-a-defect, closed by #4590)

 Test Files  2 failed | 1 passed (3)
      Tests  9 failed | 40 passed (49)

Verbatim assertion errors, matching the issue's measured table:

AssertionError: expected '1.00%' to be '1.01%' // Object.is equality
AssertionError: expected '1.02%' to be '1.03%' // Object.is equality
AssertionError: expected '1.4%' to be '1.5%' // Object.is equality
AssertionError: expected '1.05%' to be '1.06%' // Object.is equality
AssertionError: expected '99,999.99%' to be '100,000.00%' // Object.is equality
AssertionError: expected '9,007,199,254,740,990%' to be '9,007,199,254,740,991%' // Object.is equality
AssertionError: expected '99,999,999,999,999,990,000,000%' to be '100,000,000,000,000,000,000,000%' // Object.is equality

The de-DE case in that run is quoted separately rather than pasted, because the runner emits a raw U+00A0 inside both strings and pasting it here would silently turn into an ordinary space:

AssertionError: expected '1,00' U+00A0 '%' to be '1,01' U+00A0 '%' // Object.is equality

The 40 that passed on the RED side are the must-not-change half — every convention pin, every negative-sign pin, the affix-parity check and the scaling pins were green before the fix, which is what makes them evidence rather than decoration.

Green after the fix: Test Files 5 passed (5) / Tests 64 passed (64).

Reverse verification took the fix out with git checkout origin/main -- packages/fields/src/index.tsx (never git stash — the stash stack is shared across worktrees), re-ran to the same 9 red, then restored from a patch file and confirmed the restore with sha256:

packages/fields/src/index.tsx: OK
RESTORE BYTE-IDENTICAL

Moving pins, each declared

  1. percent-cell-vs-measure-4576.test.ts — the NOT-a-defect pin becomes an AGREEMENT pin, declared in place with before/after. Before: formatPercent(1.005, 2, 'en-US') was '1.00%' against the measure's '1.01%'. After: both '1.01%'. It also now asserts the two AGAINST EACH OTHER, not just against two literals, so a future divergence at either end fails whatever they happen to render.
  2. MAX_SAFE_INTEGER — was 9,007,199,254,740,990%, now 9,007,199,254,740,991% (a 1 that had turned into a 0).
  3. 1e23 — was 99,999,999,999,999,990,000,000%, now 100,000,000,000,000,000,000,000%.

#4565's formatPercent suite (percent-formatter-locale-4553.test.ts) has ZERO moving pins — it passed untouched on both sides of the fix, and is visible as the 1 passed file in the red run above. Every one of its 25 assertions was measured against both routes beforehand; none differ.

Must-not-change proofs

  • Convention: 0 of 720 combinations differ. Ten locales pinned byte-exact at a magnitude and precision where no tie is in play, so they are green on both sides.
  • Affix parity asserted, not just measured: a case in the suite compares formatPercent's affix (digits stripped) against Intl's own style: 'percent' affix for each of the ten locales, so a future convention drift fails here.
  • Percent SCALING untouched: percentDisplayValue (a stored fraction below 1 scales by 100, a value at or above 1 passes through) decides WHICH number is rendered; this card changes only HOW. Pinned unmoved, including the boundary at exactly 1.
  • packages/core/src/utils/ not touched — this PR consumes percentPoints, it does not edit it. Diff is 4 files, all in packages/fields plus the changeset.

.d.ts measurement and grading

dist/ deleted and no tsconfig.tsbuildinfo present (this package generates declarations via unplugin-dts, not tsc project references), then rebuilt:

ALL .d.ts BYTE-IDENTICAL (before vs after)
index.d.ts byte-identical

All 76 declaration files hash-identical — expected, since no signature changed. Graded '@object-ui/fields': minor anyway, on the published-behaviour-move precedent (#4495 / #4271 / #4479): the rendered output of an exported formatter moves for 27,577 of 1,200,003 measured en-US forms. Both facts are reported plainly rather than letting the identical .d.ts argue for a patch. Never major.

Verification

step result
build closure ('@object-ui/fields^...' + the package itself) green
vitest run — fields, plugin-grid, plugin-gantt, plugin-dashboard, plugin-report, core, i18n 393 files / 5511 tests passed
vitest run — app-shell, components, react, plugin-detail, plugin-list, plugin-form, plugin-view, mobile 733 files / 7284 passed, 1 skipped
turbo run type-check --filter='...@object-ui/fields' (PREFIX = downstream consumers) 54 successful, 54 total
eslint vs an origin/main compare worktree 0 errors both sides; 801 warnings both sides — net +0/+0
gates: control-bytes, phantom-dependencies, changeset-presence, changeset-no-major, changeset-fixed, type-check-coverage, lint-coverage, doc-links all PASS

Consumer census for formatPercent (grepped, then every consuming package's suite swept): packages/plugin-grid/src/ObjectGrid.tsx, packages/plugin-gantt/src/ObjectGantt.tsx, packages/plugin-dashboard/src/recordFields.tsx, and PercentCellRenderer inside packages/fields itself.

Byte discipline

Every non-ASCII character in a test expectation is a backslash-u escape in the source, never a pasted byte — three of them (U+00A0, U+202F, U+061C) are invisible on screen, so a raw byte would leave the file holding a byte where a reader greps for the escape text. node scripts/check-control-bytes.mjs passes, and a codepoint-exact self-scan over all four touched files (including the untracked ones) finds no control bytes and no raw invisible characters.

The same hazard is why the two convention tables above are written in U+XXXX notation. The first revision of this body pasted them literally, and the ar-EG rows lost their trailing U+061C outright between composing and storing — the exact failure the notation prevents, reproduced live in this PR's own description.


Generated by Claude Code

…up and extremes keep every digit (#4590)

`formatPercentBody` rendered a value already in percentage POINTS through
`Intl`'s `style: 'percent'`, which expects a FRACTION, so it divided by
100 for `Intl` to multiply straight back. That round trip is not
value-preserving: `Intl` formats from the shortest decimal
representation of the double it is handed, and the quotient's is not the
authored one — `1.005` is `1.005`, but `1.005 / 100` is
`0.010049999999999999`, which percent-scales to `1.0049999999999999` and
rounds DOWN. A stored 1.005 at 2 decimals rendered `1.00%` where half-up
is `1.01%`.

The body now renders through `style: 'percentPoints'` (the option #4576
/ PR #4589 added for exactly this) with no scaling round trip.

Measured on this call shape, old route vs new: 720 combinations
(10 locales x 18 values x 4 precisions) — 0 convention differences, 130
numeral differences, the same 13 in every locale. On the wide en-US grid
27,577 of 1,200,003 forms move. The extremes are digit-exact again:
MAX_SAFE_INTEGER points rendered `9,007,199,254,740,990%` and now render
`9,007,199,254,740,991%`.

The locale percent CONVENTION is unchanged — this is numeral-only.
Percent SCALING (`percentDisplayValue`) is upstream of the render and
untouched; both are pinned unmoved.

The #4576 cross-surface pin flips from NOT-a-defect to an AGREEMENT pin,
declared in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 5:02pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-D0-oMWPf.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.33KB 108.47KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 163.56KB 44.83KB
fields (index.js) 230.37KB 57.17KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.04KB 31.57KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.93KB 60.01KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 189.37KB 50.33KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.38KB 11.09KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.09KB 20.56KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.64KB 9.44KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.26KB 0.67KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 17:16
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 52d878a Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4590-percent-tie-halfup branch August 13, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant