Skip to content

fix(fields): formatPercent groups and follows the display locale — the last tooltip channel (#4553) - #4565

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4553-percent-fields
Aug 13, 2026
Merged

fix(fields): formatPercent groups and follows the display locale — the last tooltip channel (#4553)#4565
yinlianghui merged 1 commit into
mainfrom
claude/issue-4553-percent-fields

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4553 — phase 2, closing the card. Phase 1 (numbers and currency) landed as #4557.

The defect, and why grouping comes with it

#4557 threaded the gantt tooltip's number and currency rows and measured that percent could not follow: formatPercent(value, precision) took no locale parameter, and its whole body was `${percentDisplayValue(value).toFixed(precision)}%`. It built no Intl.NumberFormat and never reached formatDisplayNumber — so unlike its siblings it did not render in the MACHINE's locale. It rendered in no locale: an ASCII decimal mark, never a grouping separator, byte-identical on every machine.

That is why the ruled fix is grouping and locale in one change. 1235% was wrong in en-US too, not only in German:

before after
en, 1234.5 1235% 1,235%
de, 1234.5 1235% 1.235 %
de, 80 80% 80 %

The English output MOVES, and that is the fix, not a regression. It moves only from four digits up; 80%, 12.5%, 33.33% are byte-identical. German changes at every magnitude because the no-break space before the sign is part of the locale's percent convention — which is what routing through Intl buys over appending a literal %.

The scaling contract is untouched: percentDisplayValue still disambiguates a fraction-stored percent (0.8 becomes 80%) from a whole one, so the list cell and the dashboard measure formatter still agree.

Consumer census — one more than the ruling named

The ruling asked for a census. It found four call sites, in four packages:

consumer site note
fields PercentCellRenderer named; two paths, see below
plugin-gantt tooltip percent row named; flips #4557's inversion pin
plugin-dashboard renderFieldValue the real dashboard consumer
plugin-grid mobile card percent cell not named — census-found

Two census findings worth the reviewer's attention:

  1. PercentCellRenderer had a second, more primitive path. Its whole-percent branch (progress / completion fields, which store 0-100 and must skip the fraction scaling) was a bare toFixed call that bypassed formatPercent entirely. Threading only the other half would have made one grid internally inconsistent — a progress column ungrouped and unlocalized beside a rate column that was neither — so both branches now share one locale-aware body and differ only in the scaling policy, which is all the branch was ever about.
  2. The "dashboard measure formatter" is not a formatPercent consumer. formatMeasure in @object-ui/core has its own percent path (toLocaleString(undefined, ...)) and never calls formatPercent, so DatasetWidget / ObjectMetric are outside this change's blast radius. It does carry the same machine-locale defect in its own right — reported separately, not fixed here.

renderFieldValue is a plain function, not a component, so it takes the locale as an optional fourth parameter beside the tenantCurrency already threaded that way; both of its callers pass it and declare it in their memo dependency arrays.

Verification

Red-first, directions predicted in writing in each test file's header before running. Runner: node v22.22.2 / ICU 78.2 / machine locale en-US.

Reverse verification by reverting the six source files (git diff to a patch + git checkout --, never git stash), keeping the new expectations:

Tests  18 failed | 14 passed (32)

18 red is the full predicted set — the en grouping move, the de forms, both renderer paths, all four consumers, and the moved gantt pin. The 14 green are the must-not-change pins. Restore confirmed byte-identical by sha256 across all six files.

Green after: Test Files 5 passed (5), Tests 32 passed (32). Whole-package sweep over all four affected packages: Test Files 248 passed (248), Tests 2757 passed (2757) — no pre-existing percent assertion anywhere in the repo moved.

Type surface reverse-verified against the REBUILT .d.ts, both directions: formatPercent(80, 0, 'de') accepts, formatPercent(80, 0, 123) is rejected with TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

Other gates: both tsc passes green for each of the four packages; eslint 0 errors in all four; check:control-bytes, check:phantom-deps, changeset:check green.

Pin moved (declared)

ObjectGantt.numberLocale.test.tsx asserted Ratio=1235% as #4557's inversion evidence, with a comment saying it should go red the day formatPercent grew a locale. This is that day, so the case now asserts Ratio=1.235 % and is relabelled. The en expectation in that file moved too — the one line in that case that is not byte-identical, for the grouping reason above. A new case asserts every row of one tooltip finally agreeing on one convention, which is the card's original complaint, closed.

One test that is honestly labelled rather than quietly wrong

The dashboard file's "re-formats when the tenant locale changes after first paint" case was written to pin the memo dependency. It does not, and says so in place. Measured both ways: with displayLocale removed from the dependency array and the argument still threaded, all five cases still passed — the provider change makes that component refetch, which gives finalData a fresh identity and re-runs the memo on its own. Making the data source module-constant (#4554's masking-path lesson) removes one masking path but not that one. The dependency is still declared, because exhaustive-deps requires it and because it is what keeps the cells correct if the refetch ever stops coinciding — but it is guarded by reasoning, not by that test, and the file records that so a later reader does not mistake it for a pin.

Bumps

Per each package's own .d.ts diff, measured in both directions and reproducible:


Generated by Claude Code

`formatPercent(value, precision)` took no locale and never touched `Intl` —
its whole body was `${percentDisplayValue(value).toFixed(precision)}%`. So
unlike its siblings it did not render in the machine's locale; it rendered
in NO locale: an ASCII decimal mark, never grouped, identical on every
machine.

Because it never grouped, `1235%` was wrong in en-US too, so grouping and
locale land together and the English output MOVES at four digits and up
(`1235%` -> `1,235%`). German changes at every magnitude (`1.235 %`), the
no-break space before the sign being part of the locale's convention.

Consumers threaded in the same change, per the repo's parameter-with-its-
consumers rule: PercentCellRenderer (both scaling paths), the gantt tooltip
row, the grid mobile card cell, and the dashboard's renderFieldValue plus
its two memoized callers.

Bumps follow each package's own .d.ts diff, measured both ways: fields and
plugin-dashboard minor (entry-visible optional parameter), plugin-gantt and
plugin-grid patch (byte-identical .d.ts).

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 10:38am

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-Blymy7nn.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.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
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) 120.99KB 31.55KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
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.16KB 10.96KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
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) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
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

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3) — and #4553 closes with this: the gantt tooltip's every row (number, currency, percent, date) now agrees on one display locale, pinned in a single German-convention case.

  • The census corrected the ruling twice, both in the project's favour: the "dashboard measure formatter" is a PARALLEL implementation (filed as [core] formatMeasure renders every dashboard measure in the MACHINE locale — the last parallel implementation outside the display-locale channel #4566 with its design fork framed, not silently absorbed), and a fourth consumer (ObjectGrid's mobile card percent) was found and threaded. The PercentCellRenderer discovery is the census's best fruit — the whole-percent branch's bare toFixed would have left one grid internally inconsistent, so both branches now share one locale-aware body differing only in scaling policy, with the 0.5→1% semantics pinned.
  • The en grouping MOVE is handled exactly as ruled: labelled as the fix, thresholded (four digits up; 80%/12.5%/0% byte-identical), documented with a before/after table in the changeset, and 13 float-edge values measured through the /100→Intl round trip.
  • Pin moves are precisely the authorized set, including the honest annotation inside the "byte-identical" en case whose Ratio line moved for the grouping reason.
  • The HONESTY CORRECTION is this report's centerpiece: the dep-array case was measured to NOT isolate its dependency (the provider change refetches, fresh finalData re-runs the memo regardless), the harness masking path was fixed first to be sure, and the file now states the dependency is reasoning-guarded rather than test-pinned. A false pin declined at the cost of admitting it — the standard.
  • dts measured both ways across 136 files with the type-level TS2345 reverse probe; grading exactly per shape (fields/dashboard minor, gantt/grid patch). 248-file/2757-test four-package sweep green; fix(plugin-gantt): tooltip numbers and currency follow the display locale (#4553) #4557 pins untouched; CI 20/20 per-job.

Auto-merge armed (squash) — landing verified per the merge-queue discipline. #4566 enters the pool with a PM ruling to follow.


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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[plugin-gantt] tooltip number / currency / percent rows render in the MACHINE locale, next to date rows that follow the display locale

2 participants