Skip to content

fix(fields): currency formatting follows each currency's ISO 4217 minor-unit width - #4413

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4361-currency-minor-units
Aug 12, 2026
Merged

fix(fields): currency formatting follows each currency's ISO 4217 minor-unit width#4413
yinlianghui merged 2 commits into
mainfrom
claude/issue-4361-currency-minor-units

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes#4361

Both currency formatting paths in packages/fields picked a fraction-digit width and handed it to Intl.NumberFormat, which overrides the digit count Intl already knows for the currency being rendered. formatCurrency derived its width from the value's wholeness alone (isWhole ? 0 : 2 — a literal 2 for every currency on earth); CurrencyField defaulted an undeclared precision to the same literal. A yen amount was printed with cents the currency does not have, a dinar amount with one digit fewer than it does.

Both call sites now derive the width from the currency itself and switch wholeness against that.

Measured before/after

Measured on node 22 with full ICU (icu 78.2), display locale en-US unless noted. The ICU separator between a currency code and the amount is U+00A0, normalized to a space for this table.

currencyvaluebeforeafter
JPY1234.5¥1,234.50¥1,235
JPY1234¥1,234¥1,234
KWD1.5KWD 1.50KWD 1.500
KWD1KWD 1KWD 1
KWD1234.5678KWD 1,234.57KWD 1,234.568
CLP1234.5CLP 1,234.50CLP 1,235
ISK99.5ISK 99.50ISK 100
BHD2.5BHD 2.50BHD 2.500
JPY (ja-JP)1234.5¥1,234.50¥1,235
KWD (de-DE)1.51,50 KWD1,500 KWD
USD1234.5$1,234.50$1,234.50
USD1234$1,234$1,234
USD1234.56$1,234.56$1,234.56
EUR (de-DE)1234.51.234,50 €1.234,50 €
CNY (zh-CN)1234.5¥1,234.50¥1,234.50

Every 2-decimal row is byte-identical, which is the acceptance evidence: the #4033 and #4332 / #4362 pins pass unchanged.

The whole-number convention is extended, not retired

Dropping both bounds and letting Intl decide would give JPY and KWD the right digits and simultaneously turn $1,234 back into $1,234.00 — the Salesforce convention formatCurrency documents and #4033 pinned. So the wholeness switch stays and is extended consistently: a whole amount drops the fraction for every currency, so KWD 1 renders KWD 1 rather than the KWD 1.000 a bare Intl default would give. Those whole-amount cases are controls in the test file, not decorations — a fix that retired the convention would pass the digit-count pins and fail these.

The derivation

packages/fields/src/currency.ts gains currencyFractionDigits(code), memoized per code, probing Intl.NumberFormat(undefined, { style: 'currency', currency }).resolvedOptions().maximumFractionDigits.

Two measured decisions behind it:

  • The probe carries no locale. Across en-US / de-DE / ja-JP / ar-KW / zh-CN / fr-FR / pl-PL / es-ES and the runtime default, every locale answers the same count for the same code — it comes from CLDR currencyData, keyed by the currency, not by who reads it. Dropping the locale loses nothing and removes a failure mode: a malformed locale tag makes Intl.NumberFormat throw RangeError: Incorrect locale information provided, which would turn a bad locale into a wrong currency width. It also makes the code the whole cache key.
  • Memoized because the call site formats lists.CurrencyCellRenderer runs once per grid cell. Measured over 200k iterations on node 22: 24.3us per uncached probe against 0.02us cached, so a 500-row grid would otherwise pay about 12ms per render pass for a value that cannot change.

An invalid code makes the probe throw exactly as Intl does; it is caught and falls back to 2, so the callers' bad-currency fallbacks (NOT_A_CODE 1234.50, pinned by #4332) stay byte-identical. A well-formed but unknown code such as ZZZ does not throw — ICU answers 2 — so it needs no special case.

CurrencyField.precision — the measurement the ruling asked for

The ruling's stop condition was whether "absent" is distinguishable from "defaulted 2" by the time the renderer sees it. It is, so this half landed:

whereshapecarries a materialized 2?
CurrencyFieldMetadata.precision (packages/types/src/field-types.ts)precision?: numberno
the currency field's precision in @objectstack/spec@17.0.0-rc.6z.ZodOptional(z.ZodNumber), no .default()no
CurrencyConfigSchema.precision (packages/spec/src/data/field.zod.ts)z.number().int().min(0).max(10).default(2)yes
the ?? 2 itselfCurrencyField.tsxit was the renderer's own

The default that exists is on currencyConfig.precision — a different key on a different object, which this widget never reads. The field's own key arrives as undefined, so the renderer can act on it.

Behavior, per the ruling: an explicitly authoredprecision wins (authored metadata keeps priority), so a JPY field declaring precision: 2 still renders ¥1,234.50. Only an absent one derives from the currency.

The derived value is the widget's one precision, so it also reaches the edit affordances — deliberate, and pinned. Leaving step and the blur rounding at 2 would produce a JPY field that displays whole yen while offering a 0.01 spinner step and rounding typed input to 1234.56 yen:

fieldstep beforestep afterblur on 1234.56 beforeafter
currency: JPY, no precision0.0111234.561235
currency: KWD, no precision0.010.0011234.561234.56
currency: JPY, precision: 20.010.011234.561234.56
currency: USD, no precision0.010.011234.561234.56

Upstream contract card

Whether publish-time validation should reject a declared precision that contradicts the currency's ISO 4217 digits is a spec question, not a renderer question, and is filed contract-first as objectstack-ai/objectstack#7918 with the measured table and the ruling quoted. No coupling: the renderer behavior above stands whichever way that card is answered — if it is answered "reject", the contradicting combination simply stops reaching the renderer.

Verification

Reverse verification (predicted first, then measured, per half)

Taken with git checkout HEAD~1 -- path and restored with git checkout HEAD -- path — never git stash, whose stack lives in the common .git and is shared with every other agent's worktree.

revertedpredictedmeasured
index.tsx onlyformatCurrency pins red with today's renderings; USD/EUR controls stay green; all 16 CurrencyField pins stay greenexactly that: 13 failed / 29 in formatCurrency.minorUnits, CurrencyField.minorUnits 16/16 green
CurrencyField.tsx onlythe absent-precision, step and blur pins red; authored-precision and USD controls green; all 29 formatCurrency pins stay greenexactly that: 7 failed / 16 in CurrencyField.minorUnits, formatCurrency.minorUnits 29/29 green

The cross-half greens are the load-bearing half of this: they show the two call sites are independently fixed, and that the CurrencyField pins are not passing on formatCurrency's coat-tails.

Note on the test files

ICU separates a currency code from the amount with U+00A0 while a symbol sits flush against it, so the assertions normalize that one character. In the source it is spelled as a backslash-u escape sequence rather than a pasted byte: that keeps the pins about the digit count this card is about rather than about ICU's spacing, and it keeps the character findable by grep in the spelling someone would actually search for. A raw U+00A0 renders as nothing and is unfindable in both spellings, which is the same class of harm the repo's control-byte discipline exists for — and it is easiest to introduce precisely when writing about the character, which happened twice while preparing this change and was caught by a self-scan both times.


Generated by Claude Code

…or-unit width
Both currency paths in packages/fields handed Intl a hardcoded fraction-digit
width, overriding the digit count Intl already knows for the currency being
rendered: formatCurrency switched wholeness against a literal 2, and
CurrencyField defaulted an undeclared precision to the same literal. JPY
rendered with cents it does not have, KWD one digit short of the three it does.
Both call sites now derive the width from the currency itself (memoized
resolvedOptions().maximumFractionDigits) and switch wholeness against that. The
whole-number convention #4033/#4332 pinned is extended, not retired: a whole
amount still drops the fraction for every currency (KWD 1, not KWD 1.000), and
two-decimal currencies are byte-identical.
On CurrencyField an explicitly authored precision still wins; only an absent one
derives. Whether a declared precision contradicting the currency's ISO digits
should be rejected at publish time is filed upstream, contract-first.
Fixes#4361
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 12, 2026 4:27am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-Bs0H8gdJ.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.20KB108.43KB
core (index.js)2.99KB1.14KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)153.42KB41.19KB
fields (index.js)228.69KB56.74KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.33KB1.20KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)62.18KB17.67KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)120.57KB31.32KB
plugin-designer (index.js)211.16KB42.76KB
plugin-detail (index.js)239.03KB59.77KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)187.99KB49.92KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)110.21KB26.79KB
plugin-map (index.js)18.05KB5.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.99KB10.74KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM 复核 (session session_017Qqyix2QcnpUC9XeYVDzx3), closes #4361.

  • The stop condition was measured, not assumed, and measured NOT to trigger: precision? carries no default at the type, the spec, or anywhere except the renderer's own ?? 2 — so authored-wins/absent-derives landed exactly as ruled. The deliberate widening (derived precision reaching step and blur rounding — JPY offering whole-yen steps) is accepted as the correct product consequence, pinned and argued rather than smuggled.
  • The ICU executable assertion is the standout: under small-ICU the fix would look correct while rendering the same wrong output — pinning the digit table as a runnable check instead of a comment closes that silent failure mode, and CI's shards confirmed its Intl agrees.
  • Red-first with real controls (the 25 passing pre-fix are controls in fact), the helper-unwired-first deviation correctly preserved the red evidence from ESM linking failure, and the cross-half greens are the load-bearing proof of independence. The flock-timeout leaving a reverted file — caught, and the atomically-restructured retry with a restore trap — is process capture the loop keeps.
  • objectstack#7918 joins the cross-repo await list (no coupling either way, as the report correctly notes); CURRENCY_SYMBOLS in CurrencyField.tsx is dead — the symbol it would supply is inlined as a ternary two lines below #4414 (the dead symbol map) is triaged next.

Flipping ready + arming auto-merge.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 04:39
@yinlianghui
yinlianghui added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 0f21348Aug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4361-currency-minor-units branch August 12, 2026 04:39
Sign up for freeto 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.

Currency formatting overrides each currency's own fraction-digit convention: JPY renders ¥1,234.50, KWD renders KWD 1.50

2 participants

@yinlianghui@claude