From fe87575fdb42ec863e7e6e7a9eaeed4dfead0c2a Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 7 May 2026 19:23:19 +0530 Subject: [PATCH 1/7] fix(amount): added bigInt and hideCurrency support in amount component --- .../content/docs/components/amount/demo.ts | 38 ++++++-- .../content/docs/components/amount/index.mdx | 9 +- .../content/docs/components/amount/props.ts | 22 ++++- .../amount/__tests__/amount.test.tsx | 65 +++++++++++++ .../raystack/components/amount/amount.tsx | 97 +++++++++++++------ 5 files changed, 192 insertions(+), 39 deletions(-) diff --git a/apps/www/src/content/docs/components/amount/demo.ts b/apps/www/src/content/docs/components/amount/demo.ts index 528cd8bd1..3c12b4314 100644 --- a/apps/www/src/content/docs/components/amount/demo.ts +++ b/apps/www/src/content/docs/components/amount/demo.ts @@ -46,6 +46,10 @@ export const playground = { groupDigits: { type: 'checkbox', defaultValue: true + }, + hideCurrency: { + type: 'checkbox', + defaultValue: false } }, getCode @@ -119,6 +123,17 @@ export const currencyDisplayDemo = { ` }; +export const hideCurrencyDemo = { + type: 'code', + code: ` + + {/* 12.99 */} + {/* 1,299 */} + {/* 12.99 — currencyDisplay is ignored */} + + ` +}; + export const groupDigitsDemo = { type: 'code', code: ` @@ -149,13 +164,24 @@ export const withTextDemo = { export const largeNumbersDemo = { type: 'code', code: ` - - {/* For large numbers, use string to maintain precision */} + + {/* + For large numbers, use string (supports decimals) or bigint (integer-only) + to maintain precision + */} {/* $9,999,999,999,999.99 */} - {/* $10,000,100,091,636,935 */} - - {/* Numbers exceeding safe integer limit will show warning in console */} - {/* Will show warning */} + {/* $10,000,100,091,636,935 */} + + {/* + BigInt is always treated as major units — valueInMinorUnits is ignored + */} + {/* $9,999,999,999,999,999,999.00 */} + + {/* + Numbers exceeding safe integer limit will show warning in console + */} + {/* Exceeds Number.MAX_SAFE_INTEGER (~9 × 10^15) — logs a console warning */} ` }; diff --git a/apps/www/src/content/docs/components/amount/index.mdx b/apps/www/src/content/docs/components/amount/index.mdx index efc8d66b4..c166afe6d 100644 --- a/apps/www/src/content/docs/components/amount/index.mdx +++ b/apps/www/src/content/docs/components/amount/index.mdx @@ -12,6 +12,7 @@ import { localeDemo, hideDecimalsDemo, currencyDisplayDemo, + hideCurrencyDemo, groupDigitsDemo, withTextDemo, largeNumbersDemo, @@ -61,13 +62,19 @@ Formats and displays monetary values with locale and currency support. +### hideCurrency + +Render only the formatted number, without any currency symbol, code, or name. Locale-driven separators and decimal places are preserved. + + + ### groupDigits ### Large Numbers -For numbers larger than JavaScript's safe integer limit (2^53 - 1), pass the value as a string to maintain precision. +For numbers larger than JavaScript's safe integer limit (2^53 - 1), pass the value as a `string` (supports decimals) or a `bigint` (integer-only). BigInt values are always treated as already in major units, so `valueInMinorUnits` is ignored for them. diff --git a/apps/www/src/content/docs/components/amount/props.ts b/apps/www/src/content/docs/components/amount/props.ts index c6a7fb9e6..5ec8a654b 100644 --- a/apps/www/src/content/docs/components/amount/props.ts +++ b/apps/www/src/content/docs/components/amount/props.ts @@ -1,14 +1,18 @@ export interface AmountProps { /** - * The monetary value to display - * For large numbers (> 2^53), pass the value as string to maintain precision + * The monetary value to display. + * For exact precision beyond 2^53, pass either: + * - a `string` — supports decimals (e.g. "1299" or "12.99") + * - a `bigint` — integer-only; treated as already in major units, so + * `valueInMinorUnits` is ignored when value is a bigint * @default 0 * @example * valueInMinorUnits=true: 1299 => "$12.99" * valueInMinorUnits=false: 12.99 => "$12.99" - * Large numbers: "999999999999999" => "$9,999,999,999,999.99" + * Large strings: "999999999999999" => "$9,999,999,999,999.99" + * BigInt: 1299n => "$1,299.00" (always major units) */ - value: number | string; + value: number | string | bigint; /** * ISO 4217 currency code @@ -65,4 +69,14 @@ export interface AmountProps { * @default true */ groupDigits?: boolean; + + /** + * Render the formatted number without a currency symbol, code, or name. + * Locale-driven separators, grouping, and fraction digits are preserved. + * When true, `currencyDisplay` is ignored. + * @default false + * @example + * => "12.99" + */ + hideCurrency?: boolean; } diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index 65c2aa712..c196576a5 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -203,5 +203,70 @@ describe('Amount', () => { expect(element).toBeInTheDocument(); consoleSpy.mockRestore(); }); + + it('handles negative string values in minor units', () => { + render(); + expect(screen.getByText('-$12.99')).toBeInTheDocument(); + }); + }); + + describe('BigInt support', () => { + it('formats a bigint as major units regardless of valueInMinorUnits', () => { + render(); + expect(screen.getByText('$1,299.00')).toBeInTheDocument(); + }); + + it('matches the major-units result when valueInMinorUnits is false', () => { + render(); + expect(screen.getByText('$1,299.00')).toBeInTheDocument(); + }); + + it('preserves precision beyond Number.MAX_SAFE_INTEGER', () => { + render(); + expect( + screen.getByText('$9,999,999,999,999,999,999.00') + ).toBeInTheDocument(); + }); + + it('formats negative bigint values', () => { + render(); + expect(screen.getByText('-$1,299.00')).toBeInTheDocument(); + }); + + it('formats bigint with a zero-decimal currency', () => { + render(); + expect(screen.getByText('¥1,299')).toBeInTheDocument(); + }); + + it('does not warn about safe integer limit for bigint values', () => { + const consoleSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => null); + render(); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('hideCurrency', () => { + it('hides the currency symbol while preserving formatting', () => { + render(); + expect(screen.getByText('12.99')).toBeInTheDocument(); + }); + + it('hides currency for bigint values', () => { + render(); + expect(screen.getByText('1,299')).toBeInTheDocument(); + }); + + it('overrides currencyDisplay when set', () => { + render(); + expect(screen.getByText('12.99')).toBeInTheDocument(); + }); + + it('respects the currency for decimal-place math even when hidden', () => { + render(); + expect(screen.getByText('1,299')).toBeInTheDocument(); + }); }); }); diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 8ee095e1f..61bd902d8 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -2,14 +2,18 @@ import { type ComponentProps } from 'react'; export interface AmountProps extends ComponentProps<'span'> { /** - * The monetary value to display - * For large numbers (> 2^53), pass the value as string to maintain precision +\ * The monetary value to display. + * For exact precision beyond 2^53, pass either: + * - a `string` — supports decimals (e.g. "1299" or "12.99") + * - a `bigint` — integer-only; treated as already in major units, so + * `valueInMinorUnits` is ignored when value is a bigint * @default 0 * @example * valueInMinorUnits=true: 1299 => "$12.99" * valueInMinorUnits=false: 12.99 => "$12.99" + * bigint: 1299n => "$1,299.00" (always major units) */ - value: number | string; + value: number | string | bigint; /** * ISO 4217 currency code @@ -66,6 +70,16 @@ export interface AmountProps extends ComponentProps<'span'> { * @default true */ groupDigits?: boolean; + + /** + * Render the formatted number without a currency symbol, code, or name. + * Locale-driven separators, grouping, and fraction digits are preserved. + * When true, `currencyDisplay` is ignored. + * @default false + * @example + * => "12.99" + */ + hideCurrency?: boolean; } /** @@ -151,6 +165,7 @@ export const Amount = ({ maximumFractionDigits, groupDigits = true, valueInMinorUnits = true, + hideCurrency = false, ...props }: AmountProps) => { try { @@ -160,7 +175,7 @@ export const Amount = ({ ) { console.warn( `Warning: The number ${value} exceeds JavaScript's safe integer limit (${Number.MAX_SAFE_INTEGER}). ` + - 'For large numbers, pass the value as a string to maintain precision.' + 'For large numbers, pass the value as a bigint or string to maintain precision.' ); } @@ -171,37 +186,63 @@ export const Amount = ({ const decimals = getCurrencyDecimals(validCurrency); - // Handle minor units - use string manipulation for strings and Math.pow for numbers - const baseValue = - valueInMinorUnits && decimals > 0 - ? typeof value === 'string' - ? value.slice(0, -decimals) + '.' + value.slice(-decimals) - : value / Math.pow(10, decimals) - : value; - - // Remove decimals if hideDecimals is true - handle string and number separately - // Note: Not all numbers passed is converted to string as methods like Math.trunc - // or toString cannot handle large numbers thus, we need to handle it separately (large numbers passed in value throws console warning). - const finalBaseValue = hideDecimals - ? typeof baseValue === 'string' - ? baseValue.split('.')[0] - : Math.trunc(baseValue) - : baseValue; - - const formattedValue = new Intl.NumberFormat(locale, { - style: 'currency' as const, - currency: validCurrency.toUpperCase(), - currencyDisplay, + // Convert minor → major units. Three input shapes: bigint, string, number. + // bigint is always treated as already in major units (it cannot represent + // fractions), so `valueInMinorUnits` is ignored for bigint. + let baseValue: number | string | bigint; + if (typeof value === 'bigint') { + baseValue = value; + } else if (valueInMinorUnits && decimals > 0) { + if (typeof value === 'string') { + const isNegative = value.startsWith('-'); + const digits = isNegative ? value.slice(1) : value; + const padded = digits.padStart(decimals + 1, '0'); + const major = padded.slice(0, -decimals); + const minor = padded.slice(-decimals); + baseValue = `${isNegative ? '-' : ''}${major}.${minor}`; + } else { + baseValue = value / Math.pow(10, decimals); + } + } else { + baseValue = value; + } + + // Remove decimals when hideDecimals is true. bigint has no decimals, so + // it's a no-op there. + let finalBaseValue: number | string | bigint; + if (!hideDecimals) { + finalBaseValue = baseValue; + } else if (typeof baseValue === 'bigint') { + finalBaseValue = baseValue; + } else if (typeof baseValue === 'string') { + finalBaseValue = baseValue.split('.')[0]; + } else { + finalBaseValue = Math.trunc(baseValue); + } + + const formatOptions: Intl.NumberFormatOptions = { minimumFractionDigits: hideDecimals ? 0 : minimumFractionDigits, maximumFractionDigits: hideDecimals ? 0 : maximumFractionDigits, - useGrouping: groupDigits + useGrouping: groupDigits, + ...(hideCurrency + ? { style: 'decimal' } + : { + style: 'currency', + currency: validCurrency.toUpperCase(), + currencyDisplay + }) + }; + + const formattedValue = new Intl.NumberFormat( + locale, + formatOptions // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`. - } as Intl.NumberFormatOptions).format(finalBaseValue); + ).format(finalBaseValue); return {formattedValue}; } catch (error) { console.error('Error formatting amount:', error); - return {value}; + return {String(value)}; } }; From 4bd4039ef0d19a970f9abb3888e12a31c3083903 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 8 May 2026 10:51:13 +0530 Subject: [PATCH 2/7] updates to resolve comments --- .../amount/__tests__/amount.test.tsx | 45 ++++++++++++++- .../raystack/components/amount/amount.tsx | 55 ++++++++++++++++++- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index c196576a5..d9efe5067 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -208,6 +208,20 @@ describe('Amount', () => { render(); expect(screen.getByText('-$12.99')).toBeInTheDocument(); }); + + it('does not warn about Intl V3 support on a V3 runtime for large strings', () => { + // The test environment (Node 22) supports Intl V3, so passing a large + // string should format with full precision and never log the + // string-precision fallback warning. + const consoleSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => null); + render(); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Intl.NumberFormat V3') + ); + consoleSpy.mockRestore(); + }); }); describe('BigInt support', () => { @@ -254,9 +268,19 @@ describe('Amount', () => { expect(screen.getByText('12.99')).toBeInTheDocument(); }); - it('hides currency for bigint values', () => { + it('preserves trailing zeros for round amounts (USD)', () => { + render(); + expect(screen.getByText('12.00')).toBeInTheDocument(); + }); + + it('preserves currency fraction digits for round bigint values', () => { render(); - expect(screen.getByText('1,299')).toBeInTheDocument(); + expect(screen.getByText('1,299.00')).toBeInTheDocument(); + }); + + it('preserves currency fraction digits for a 3-decimal currency (BHD)', () => { + render(); + expect(screen.getByText('1.234')).toBeInTheDocument(); }); it('overrides currencyDisplay when set', () => { @@ -268,5 +292,22 @@ describe('Amount', () => { render(); expect(screen.getByText('1,299')).toBeInTheDocument(); }); + + it('honors explicit minimumFractionDigits over the currency default', () => { + render( + + ); + expect(screen.getByText('12.0000')).toBeInTheDocument(); + }); + + it('hideDecimals wins over the currency-default fraction digits', () => { + render(); + expect(screen.getByText('12')).toBeInTheDocument(); + }); }); }); diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 61bd902d8..21c6676c0 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -2,7 +2,7 @@ import { type ComponentProps } from 'react'; export interface AmountProps extends ComponentProps<'span'> { /** -\ * The monetary value to display. + * The monetary value to display. * For exact precision beyond 2^53, pass either: * - a `string` — supports decimals (e.g. "1299" or "12.99") * - a `bigint` — integer-only; treated as already in major units, so @@ -117,6 +117,31 @@ function isValidCurrency(currency: string): boolean { } } +/** + * Detects whether the runtime implements Intl.NumberFormat V3 string-precision + * support (Chrome 106+, Firefox 116+, Safari 15.4+, Node 19+). On V3 runtimes, + * `format(string)` preserves exact precision; on older runtimes the string is + * coerced via `Number()` first, losing precision beyond 2^53. Memoized so the + * probe runs at most once per session. + */ +let _supportsIntlStringPrecision: boolean | undefined; +function supportsIntlStringPrecision(): boolean { + if (_supportsIntlStringPrecision !== undefined) { + return _supportsIntlStringPrecision; + } + try { + // 20-digit value whose tail (`...112`) is lost when coerced to Number. + const probe = new Intl.NumberFormat('en-US').format( + '11111111111111111112' as unknown as number + ); + _supportsIntlStringPrecision = + probe.replace(/[^\d]/g, '') === '11111111111111111112'; + } catch { + _supportsIntlStringPrecision = false; + } + return _supportsIntlStringPrecision; +} + /** * Amount component for displaying monetary values. * Automatically formats currencies using Intl.NumberFormat. @@ -220,9 +245,20 @@ export const Amount = ({ finalBaseValue = Math.trunc(baseValue); } + // For style: 'decimal' (hideCurrency), Intl uses min=0/max=3 by default, + // ignoring the currency's decimal count. Mirror the currency-style behavior + // by defaulting both to `decimals` so round amounts like 1200 (USD) still + // render as "12.00" instead of "12". User-provided values win. + const resolvedMinFrac = hideDecimals + ? 0 + : (minimumFractionDigits ?? (hideCurrency ? decimals : undefined)); + const resolvedMaxFrac = hideDecimals + ? 0 + : (maximumFractionDigits ?? (hideCurrency ? decimals : undefined)); + const formatOptions: Intl.NumberFormatOptions = { - minimumFractionDigits: hideDecimals ? 0 : minimumFractionDigits, - maximumFractionDigits: hideDecimals ? 0 : maximumFractionDigits, + minimumFractionDigits: resolvedMinFrac, + maximumFractionDigits: resolvedMaxFrac, useGrouping: groupDigits, ...(hideCurrency ? { style: 'decimal' } @@ -233,6 +269,19 @@ export const Amount = ({ }) }; + if ( + typeof finalBaseValue === 'string' && + finalBaseValue.replace(/\D/g, '').length > 15 && + !supportsIntlStringPrecision() + ) { + console.warn( + 'Amount: this runtime does not support Intl.NumberFormat V3 string precision ' + + '(requires Chrome 106+, Firefox 116+, Safari 15.4+, or Node 19+). ' + + 'Large string values may lose precision when formatted. ' + + 'Pass a bigint for exact integer formatting on older runtimes.' + ); + } + const formattedValue = new Intl.NumberFormat( locale, formatOptions From 197a7387ef02e3d7d192c9d383bb3b557518c00f Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 8 May 2026 11:37:41 +0530 Subject: [PATCH 3/7] updates to resolve comments --- .../amount/__tests__/amount.test.tsx | 14 +++++++++ .../raystack/components/amount/amount.tsx | 30 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index d9efe5067..29264ead1 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -309,5 +309,19 @@ describe('Amount', () => { render(); expect(screen.getByText('12')).toBeInTheDocument(); }); + + it('clamps min when only maximumFractionDigits is provided (avoids RangeError)', () => { + // USD's default min of 2 would invert against max=1 → Intl throws. + // Clamp resolvedMinFrac down to maximumFractionDigits. + render(); + expect(screen.getByText('13.0')).toBeInTheDocument(); + }); + + it('clamps max when only minimumFractionDigits is provided (avoids RangeError)', () => { + // USD's default max of 2 would invert against min=3 → Intl throws. + // Clamp resolvedMaxFrac up to minimumFractionDigits. + render(); + expect(screen.getByText('12.990')).toBeInTheDocument(); + }); }); }); diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 21c6676c0..72430bb74 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -249,13 +249,39 @@ export const Amount = ({ // ignoring the currency's decimal count. Mirror the currency-style behavior // by defaulting both to `decimals` so round amounts like 1200 (USD) still // render as "12.00" instead of "12". User-provided values win. - const resolvedMinFrac = hideDecimals + let resolvedMinFrac = hideDecimals ? 0 : (minimumFractionDigits ?? (hideCurrency ? decimals : undefined)); - const resolvedMaxFrac = hideDecimals + let resolvedMaxFrac = hideDecimals ? 0 : (maximumFractionDigits ?? (hideCurrency ? decimals : undefined)); + // In hideCurrency mode, the currency-decimal default applied to the + // unspecified bound can invert min > max when the user provides only + // one of the two (e.g. maximumFractionDigits=1 with USD's default min + // of 2). Intl throws RangeError on inversion — clamp the *defaulted* + // side toward the user-provided side. + if ( + hideCurrency && + !hideDecimals && + resolvedMinFrac !== undefined && + resolvedMaxFrac !== undefined + ) { + if ( + minimumFractionDigits === undefined && + maximumFractionDigits !== undefined && + resolvedMinFrac > resolvedMaxFrac + ) { + resolvedMinFrac = resolvedMaxFrac; + } else if ( + maximumFractionDigits === undefined && + minimumFractionDigits !== undefined && + resolvedMaxFrac < resolvedMinFrac + ) { + resolvedMaxFrac = resolvedMinFrac; + } + } + const formatOptions: Intl.NumberFormatOptions = { minimumFractionDigits: resolvedMinFrac, maximumFractionDigits: resolvedMaxFrac, From 43b4425816e4ee27706cfccf65e1a5ba69abdd45 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 8 May 2026 12:23:50 +0530 Subject: [PATCH 4/7] simplifying hidecurrency logic --- .../amount/__tests__/amount.test.tsx | 8 +- .../raystack/components/amount/amount.tsx | 112 +++++++----------- 2 files changed, 48 insertions(+), 72 deletions(-) diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index 29264ead1..56050cae9 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -311,15 +311,15 @@ describe('Amount', () => { }); it('clamps min when only maximumFractionDigits is provided (avoids RangeError)', () => { - // USD's default min of 2 would invert against max=1 → Intl throws. - // Clamp resolvedMinFrac down to maximumFractionDigits. + // USD's default min of 2 would invert against max=1; with style:'currency' + // + formatToParts(), Intl's spec auto-clamps min → 1, so 12.99 rounds to 13.0. render(); expect(screen.getByText('13.0')).toBeInTheDocument(); }); it('clamps max when only minimumFractionDigits is provided (avoids RangeError)', () => { - // USD's default max of 2 would invert against min=3 → Intl throws. - // Clamp resolvedMaxFrac up to minimumFractionDigits. + // USD's default max of 2 would invert against min=3; with style:'currency' + // + formatToParts(), Intl's spec auto-clamps max → 3, so 12.99 pads to 12.990. render(); expect(screen.getByText('12.990')).toBeInTheDocument(); }); diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 72430bb74..3d8c7168b 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -118,29 +118,24 @@ function isValidCurrency(currency: string): boolean { } /** - * Detects whether the runtime implements Intl.NumberFormat V3 string-precision - * support (Chrome 106+, Firefox 116+, Safari 15.4+, Node 19+). On V3 runtimes, + * Whether the runtime implements Intl.NumberFormat V3 string-precision support + * (Chrome 106+, Firefox 116+, Safari 15.4+, Node 19+). On V3 runtimes, * `format(string)` preserves exact precision; on older runtimes the string is - * coerced via `Number()` first, losing precision beyond 2^53. Memoized so the - * probe runs at most once per session. + * coerced via `Number()` first, losing precision beyond 2^53. Probed once at + * module load. */ -let _supportsIntlStringPrecision: boolean | undefined; -function supportsIntlStringPrecision(): boolean { - if (_supportsIntlStringPrecision !== undefined) { - return _supportsIntlStringPrecision; - } +const SUPPORTS_INTL_STRING_PRECISION: boolean = (() => { try { // 20-digit value whose tail (`...112`) is lost when coerced to Number. const probe = new Intl.NumberFormat('en-US').format( - '11111111111111111112' as unknown as number + // @ts-expect-error TS lib types omit `string` from format() params; needed for the V3 precision probe. + '11111111111111111112' ); - _supportsIntlStringPrecision = - probe.replace(/[^\d]/g, '') === '11111111111111111112'; + return probe.replace(/[^\d]/g, '') === '11111111111111111112'; } catch { - _supportsIntlStringPrecision = false; + return false; } - return _supportsIntlStringPrecision; -} +})(); /** * Amount component for displaying monetary values. @@ -245,60 +240,24 @@ export const Amount = ({ finalBaseValue = Math.trunc(baseValue); } - // For style: 'decimal' (hideCurrency), Intl uses min=0/max=3 by default, - // ignoring the currency's decimal count. Mirror the currency-style behavior - // by defaulting both to `decimals` so round amounts like 1200 (USD) still - // render as "12.00" instead of "12". User-provided values win. - let resolvedMinFrac = hideDecimals - ? 0 - : (minimumFractionDigits ?? (hideCurrency ? decimals : undefined)); - let resolvedMaxFrac = hideDecimals - ? 0 - : (maximumFractionDigits ?? (hideCurrency ? decimals : undefined)); - - // In hideCurrency mode, the currency-decimal default applied to the - // unspecified bound can invert min > max when the user provides only - // one of the two (e.g. maximumFractionDigits=1 with USD's default min - // of 2). Intl throws RangeError on inversion — clamp the *defaulted* - // side toward the user-provided side. - if ( - hideCurrency && - !hideDecimals && - resolvedMinFrac !== undefined && - resolvedMaxFrac !== undefined - ) { - if ( - minimumFractionDigits === undefined && - maximumFractionDigits !== undefined && - resolvedMinFrac > resolvedMaxFrac - ) { - resolvedMinFrac = resolvedMaxFrac; - } else if ( - maximumFractionDigits === undefined && - minimumFractionDigits !== undefined && - resolvedMaxFrac < resolvedMinFrac - ) { - resolvedMaxFrac = resolvedMinFrac; - } - } - + // Always format in currency mode — Intl's currency-style handles fraction + // digits per the currency, locale-correct grouping/separators, and + // auto-clamps when only one of min/max is user-provided. For hideCurrency + // we then strip the currency token from the output via formatToParts(), + // which avoids the divergent defaults of style: 'decimal'. const formatOptions: Intl.NumberFormatOptions = { - minimumFractionDigits: resolvedMinFrac, - maximumFractionDigits: resolvedMaxFrac, - useGrouping: groupDigits, - ...(hideCurrency - ? { style: 'decimal' } - : { - style: 'currency', - currency: validCurrency.toUpperCase(), - currencyDisplay - }) + style: 'currency', + currency: validCurrency.toUpperCase(), + currencyDisplay, + minimumFractionDigits: hideDecimals ? 0 : minimumFractionDigits, + maximumFractionDigits: hideDecimals ? 0 : maximumFractionDigits, + useGrouping: groupDigits }; if ( typeof finalBaseValue === 'string' && finalBaseValue.replace(/\D/g, '').length > 15 && - !supportsIntlStringPrecision() + !SUPPORTS_INTL_STRING_PRECISION ) { console.warn( 'Amount: this runtime does not support Intl.NumberFormat V3 string precision ' + @@ -308,11 +267,28 @@ export const Amount = ({ ); } - const formattedValue = new Intl.NumberFormat( - locale, - formatOptions - // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`. - ).format(finalBaseValue); + const formatter = new Intl.NumberFormat(locale, formatOptions); + + let formattedValue: string; + if (hideCurrency) { + // Strip the `currency` parts; trim the result to drop the leading/ + // trailing whitespace that locales like de-DE leave behind (e.g. + // "1.234,56 €" becomes "1.234,56 " before the trim). + formattedValue = formatter + .formatToParts( + // @ts-expect-error TS lib types omit `string` from formatToParts() params, but Intl accepts numeric strings at runtime. + finalBaseValue + ) + .filter(p => p.type !== 'currency') + .map(p => p.value) + .join('') + .trim(); + } else { + formattedValue = formatter.format( + // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`. + finalBaseValue + ); + } return {formattedValue}; } catch (error) { From 6c9a6a77e70156074dd26e5ac339180d66b4dce3 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 8 May 2026 13:06:01 +0530 Subject: [PATCH 5/7] minor updates --- .../raystack/components/amount/amount.tsx | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 3d8c7168b..d141846ef 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -229,16 +229,13 @@ export const Amount = ({ // Remove decimals when hideDecimals is true. bigint has no decimals, so // it's a no-op there. - let finalBaseValue: number | string | bigint; - if (!hideDecimals) { - finalBaseValue = baseValue; - } else if (typeof baseValue === 'bigint') { - finalBaseValue = baseValue; - } else if (typeof baseValue === 'string') { - finalBaseValue = baseValue.split('.')[0]; - } else { - finalBaseValue = Math.trunc(baseValue); - } + const finalBaseValue: number | string | bigint = !hideDecimals + ? baseValue + : typeof baseValue === 'bigint' + ? baseValue + : typeof baseValue === 'string' + ? baseValue.split('.')[0] + : Math.trunc(baseValue); // Always format in currency mode — Intl's currency-style handles fraction // digits per the currency, locale-correct grouping/separators, and @@ -269,26 +266,23 @@ export const Amount = ({ const formatter = new Intl.NumberFormat(locale, formatOptions); - let formattedValue: string; - if (hideCurrency) { - // Strip the `currency` parts; trim the result to drop the leading/ - // trailing whitespace that locales like de-DE leave behind (e.g. - // "1.234,56 €" becomes "1.234,56 " before the trim). - formattedValue = formatter - .formatToParts( - // @ts-expect-error TS lib types omit `string` from formatToParts() params, but Intl accepts numeric strings at runtime. + // For hideCurrency, strip the `currency` parts and trim leading/trailing + // whitespace that locales like de-DE leave behind (e.g. "1.234,56 €" + // becomes "1.234,56 " before the trim). Otherwise format directly. + const formattedValue: string = hideCurrency + ? formatter + .formatToParts( + // @ts-expect-error TS lib types omit `string` from formatToParts() params, but Intl accepts numeric strings at runtime. + finalBaseValue + ) + .filter(p => p.type !== 'currency') + .map(p => p.value) + .join('') + .trim() + : formatter.format( + // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`. finalBaseValue - ) - .filter(p => p.type !== 'currency') - .map(p => p.value) - .join('') - .trim(); - } else { - formattedValue = formatter.format( - // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`. - finalBaseValue - ); - } + ); return {formattedValue}; } catch (error) { From adc6c7089727e706d577cb6ef010f7bb45869c3e Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 8 May 2026 13:15:33 +0530 Subject: [PATCH 6/7] linting updates --- .../raystack/components/amount/amount.tsx | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index d141846ef..624acb351 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -206,9 +206,9 @@ export const Amount = ({ const decimals = getCurrencyDecimals(validCurrency); - // Convert minor → major units. Three input shapes: bigint, string, number. - // bigint is always treated as already in major units (it cannot represent - // fractions), so `valueInMinorUnits` is ignored for bigint. + // Convert minor → major units. + // Three input shapes: bigint, string, number. + // BigInt is always treated as already in major units (it cannot represent fractions), so `valueInMinorUnits` is ignored for BigInt. let baseValue: number | string | bigint; if (typeof value === 'bigint') { baseValue = value; @@ -227,8 +227,7 @@ export const Amount = ({ baseValue = value; } - // Remove decimals when hideDecimals is true. bigint has no decimals, so - // it's a no-op there. + // Remove decimals when hideDecimals is true. BigInt has no decimals, so it's a no-op there. const finalBaseValue: number | string | bigint = !hideDecimals ? baseValue : typeof baseValue === 'bigint' @@ -237,11 +236,13 @@ export const Amount = ({ ? baseValue.split('.')[0] : Math.trunc(baseValue); - // Always format in currency mode — Intl's currency-style handles fraction - // digits per the currency, locale-correct grouping/separators, and - // auto-clamps when only one of min/max is user-provided. For hideCurrency - // we then strip the currency token from the output via formatToParts(), - // which avoids the divergent defaults of style: 'decimal'. + /** + * Always format in currency mode — Intl's currency-style handles fraction digits per the currency, + * locale-correct grouping/separators, + * and auto-clamps when only one of min/max is user-provided. + * For hideCurrency, we then strip the currency token from the output via formatToParts(), + * which avoids the divergent defaults of style: 'decimal'. + */ const formatOptions: Intl.NumberFormatOptions = { style: 'currency', currency: validCurrency.toUpperCase(), @@ -266,9 +267,12 @@ export const Amount = ({ const formatter = new Intl.NumberFormat(locale, formatOptions); - // For hideCurrency, strip the `currency` parts and trim leading/trailing - // whitespace that locales like de-DE leave behind (e.g. "1.234,56 €" - // becomes "1.234,56 " before the trim). Otherwise format directly. + /** + * For hideCurrency, strip the `currency` parts and trim leading/trailing + * whitespace that locales like de-DE leave behind + * (e.g. "1.234,56 €" becomes "1.234,56 " before the trim). + * Otherwise format directly. + */ const formattedValue: string = hideCurrency ? formatter .formatToParts( From dd559e152e7d2c554501eb36b94b1a872caf078d Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 8 May 2026 14:24:39 +0530 Subject: [PATCH 7/7] simplify intl v3 precision warning, drop runtime probe --- .../amount/__tests__/amount.test.tsx | 12 ++--- .../raystack/components/amount/amount.tsx | 46 +++++++------------ 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index 56050cae9..bbc804afc 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -209,16 +209,16 @@ describe('Amount', () => { expect(screen.getByText('-$12.99')).toBeInTheDocument(); }); - it('does not warn about Intl V3 support on a V3 runtime for large strings', () => { - // The test environment (Node 22) supports Intl V3, so passing a large - // string should format with full precision and never log the - // string-precision fallback warning. + it('warns when a string longer than 15 digits is formatted', () => { + // The component flags potential precision loss for any >15-digit string, + // letting the developer decide whether their runtime targets are at risk + // and whether to switch to bigint. const consoleSpy = vi .spyOn(console, 'warn') .mockImplementation(() => null); render(); - expect(consoleSpy).not.toHaveBeenCalledWith( - expect.stringContaining('Intl.NumberFormat V3') + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('longer than 15 digits') ); consoleSpy.mockRestore(); }); diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 624acb351..28813109f 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -117,26 +117,6 @@ function isValidCurrency(currency: string): boolean { } } -/** - * Whether the runtime implements Intl.NumberFormat V3 string-precision support - * (Chrome 106+, Firefox 116+, Safari 15.4+, Node 19+). On V3 runtimes, - * `format(string)` preserves exact precision; on older runtimes the string is - * coerced via `Number()` first, losing precision beyond 2^53. Probed once at - * module load. - */ -const SUPPORTS_INTL_STRING_PRECISION: boolean = (() => { - try { - // 20-digit value whose tail (`...112`) is lost when coerced to Number. - const probe = new Intl.NumberFormat('en-US').format( - // @ts-expect-error TS lib types omit `string` from format() params; needed for the V3 precision probe. - '11111111111111111112' - ); - return probe.replace(/[^\d]/g, '') === '11111111111111111112'; - } catch { - return false; - } -})(); - /** * Amount component for displaying monetary values. * Automatically formats currencies using Intl.NumberFormat. @@ -206,9 +186,12 @@ export const Amount = ({ const decimals = getCurrencyDecimals(validCurrency); - // Convert minor → major units. - // Three input shapes: bigint, string, number. - // BigInt is always treated as already in major units (it cannot represent fractions), so `valueInMinorUnits` is ignored for BigInt. + /** + * Convert minor → major units. + * Three input shapes: bigint, string, number. + * BigInt is always treated as already in major units (it cannot represent fractions), + * so `valueInMinorUnits` is ignored for BigInt. + */ let baseValue: number | string | bigint; if (typeof value === 'bigint') { baseValue = value; @@ -252,16 +235,21 @@ export const Amount = ({ useGrouping: groupDigits }; + /** + * Only flag strings whose digit count exceeds 15 — Number.MAX_SAFE_INTEGER + * is 9,007,199,254,740,991 (16 digits), so ≤15-digit strings round-trip + * through Number() without precision loss even on non-V3 runtimes. + */ if ( typeof finalBaseValue === 'string' && - finalBaseValue.replace(/\D/g, '').length > 15 && - !SUPPORTS_INTL_STRING_PRECISION + finalBaseValue.replace(/\D/g, '').length > 15 ) { console.warn( - 'Amount: this runtime does not support Intl.NumberFormat V3 string precision ' + - '(requires Chrome 106+, Firefox 116+, Safari 15.4+, or Node 19+). ' + - 'Large string values may lose precision when formatted. ' + - 'Pass a bigint for exact integer formatting on older runtimes.' + 'Amount: a string longer than 15 digits is being formatted. ' + + 'Older runtimes without Intl.NumberFormat V3 string-precision support ' + + '(Chrome <106, Firefox <116, Safari <15.4, Node <19) coerce strings via ' + + 'Number() first, which loses precision beyond 2^53. Pass a bigint for ' + + 'exact integer formatting if your targets include those runtimes.' ); }