fix(fields): currency formatting follows each currency's ISO 4217 minor-unit width - #4413
Merged
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
Collaborator
Author
|
ACCEPT — PM 复核 (session
Flipping ready + arming auto-merge. Generated by Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4361
Both currency formatting paths in
packages/fieldspicked a fraction-digit width and handed it toIntl.NumberFormat, which overrides the digit countIntlalready knows for the currency being rendered.formatCurrencyderived its width from the value's wholeness alone (isWhole ? 0 : 2— a literal 2 for every currency on earth);CurrencyFielddefaulted an undeclaredprecisionto 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-USunless noted. The ICU separator between a currency code and the amount is U+00A0, normalized to a space for this table.1234.5¥1,234.50¥1,2351234¥1,234¥1,2341.5KWD 1.50KWD 1.5001KWD 1KWD 11234.5678KWD 1,234.57KWD 1,234.5681234.5CLP 1,234.50CLP 1,23599.5ISK 99.50ISK 1002.5BHD 2.50BHD 2.500ja-JP)1234.5¥1,234.50¥1,235de-DE)1.51,50 KWD1,500 KWD1234.5$1,234.50$1,234.501234$1,234$1,2341234.56$1,234.56$1,234.56de-DE)1234.51.234,50 €1.234,50 €zh-CN)1234.5¥1,234.50¥1,234.50Every 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
Intldecide would give JPY and KWD the right digits and simultaneously turn$1,234back into$1,234.00— the Salesforce conventionformatCurrencydocuments and #4033 pinned. So the wholeness switch stays and is extended consistently: a whole amount drops the fraction for every currency, soKWD 1rendersKWD 1rather than theKWD 1.000a bareIntldefault 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.tsgainscurrencyFractionDigits(code), memoized per code, probingIntl.NumberFormat(undefined, { style: 'currency', currency }).resolvedOptions().maximumFractionDigits.Two measured decisions behind it:
currencyData, keyed by the currency, not by who reads it. Dropping the locale loses nothing and removes a failure mode: a malformed locale tag makesIntl.NumberFormatthrowRangeError: Incorrect locale information provided, which would turn a bad locale into a wrong currency width. It also makes the code the whole cache key.CurrencyCellRendererruns 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
Intldoes; 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 asZZZdoes not throw — ICU answers 2 — so it needs no special case.CurrencyField.precision— the measurement the ruling asked forThe 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:
CurrencyFieldMetadata.precision(packages/types/src/field-types.ts)precision?: numberprecisionin@objectstack/spec@17.0.0-rc.6z.ZodOptional(z.ZodNumber), no.default()CurrencyConfigSchema.precision(packages/spec/src/data/field.zod.ts)z.number().int().min(0).max(10).default(2)?? 2itselfCurrencyField.tsxThe 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 asundefined, so the renderer can act on it.Behavior, per the ruling: an explicitly authored
precisionwins (authored metadata keeps priority), so a JPY field declaringprecision: 2still 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. Leavingstepand the blur rounding at 2 would produce a JPY field that displays whole yen while offering a0.01spinner step and rounding typed input to 1234.56 yen:stepbeforestepafter1234.56beforecurrency: JPY, no precision0.0111234.561235currency: KWD, no precision0.010.0011234.561234.56currency: JPY,precision: 20.010.011234.561234.56currency: USD, no precision0.010.011234.561234.56Upstream contract card
Whether publish-time validation should reject a declared
precisionthat 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
expected '¥1,234.50' to be '¥1,235',expected 'KWD 1.50' to be 'KWD 1.500',expected 'ISK 99.50' to be 'ISK 100'). The controls — USD, EUR, no-currency, bad-currency,formatCompactCurrency, the whole-KWD/BHD cases, and the ICU-environment assertion — passed before the fix, so they are controls in fact and not by claim.pnpm exec vitest run packages/fields/from the repo root: 82 files, 1297 tests passed, including the Console: number cell/display renderers hardcode a groupingIntl.NumberFormat('en-US')— an ordinalField.number(a year) always renders2,026, and no field property can turn it off #4033 /formatCurrencydrops a real cents digit: an amount of 1234.5 renders$1,234.5, not the$1,234.50its own contract promises #4332 / fix(fields): formatCurrency keeps both cents digits on a fractional amount #4362 pins unchanged.formatCurrencycaller (fields,i18n,plugin-grid,plugin-dashboard,plugin-gantt,react): 305 files, 3892 tests passed.tsc --noEmitandtscgreen for@object-ui/fields, after building the dependency closure (pnpm --filter '@object-ui/fields^...' build) — the un-built closure had been masking a real error,maximumFractionDigitsbeing typednumber | undefined, now coalesced rather than asserted.check-control-bytesand the newcheck-phantom-dependenciesgate from Phantom dependency:reactresolves frompackages/corealthough core declares none — a resolution-based layering check returns the wrong answer #4394 both green after merging currentmain.Reverse verification (predicted first, then measured, per half)
Taken with
git checkout HEAD~1 -- pathand restored withgit checkout HEAD -- path— nevergit stash, whose stack lives in the common.gitand is shared with every other agent's worktree.index.tsxonlyformatCurrencypins red with today's renderings; USD/EUR controls stay green; all 16CurrencyFieldpins stay greenformatCurrency.minorUnits,CurrencyField.minorUnits16/16 greenCurrencyField.tsxonlystepand blur pins red; authored-precision and USD controls green; all 29formatCurrencypins stay greenCurrencyField.minorUnits,formatCurrency.minorUnits29/29 greenThe cross-half greens are the load-bearing half of this: they show the two call sites are independently fixed, and that the
CurrencyFieldpins are not passing onformatCurrency'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