From 3843cef545da9cbd8c3bce451d934255627f9e6a Mon Sep 17 00:00:00 2001 From: CV Date: Mon, 16 Mar 2026 21:30:49 +0800 Subject: [PATCH] fix: clamp decimals in formatAmount/formatCurrency/formatPercent to prevent RangeError When formatAmount was called with decimals=0 (e.g., in LoanSimulator simulate mode), minimumFractionDigits (hardcoded at 2) exceeded maximumFractionDigits (0), causing toLocaleString to throw RangeError that crashed the entire React tree. Also added clamping to formatCurrency and formatPercent for negative decimals values. Co-Authored-By: Claude Opus 4.6 --- src/lib/utils.ts | 14 ++++++++------ test/lib/utils.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index faf252e..5296904 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -28,10 +28,10 @@ export function formatAmount( const abs = Math.abs(num); // Tiny positive numbers: show '< 0.0001' for values > 0 and < 0.0001 if (abs > 0 && abs < 0.0001) return `< 0.0001`; - // Cap maximum decimals to 4 to avoid very long fractional displays + // Cap decimals to valid range (0-4) and ensure min <= max const maxDecimals = Math.min(4, Math.max(0, decimals)); return num.toLocaleString("en-US", { - minimumFractionDigits: 2, + minimumFractionDigits: Math.min(2, maxDecimals), maximumFractionDigits: maxDecimals, }); } @@ -51,9 +51,10 @@ export function formatCurrency( return `${currencySymbol}${abbreviateNumber(num, 1)}`; } // For smaller numbers, show with requested decimals (no grouping abbreviation) + const safeDecimals = Math.max(0, decimals); return `${currencySymbol}${num.toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, + minimumFractionDigits: safeDecimals, + maximumFractionDigits: safeDecimals, })}`; } @@ -64,9 +65,10 @@ export function formatPercent( if (value === null || value === undefined) return `0%`; const num = Number(value); if (!Number.isFinite(num)) return `0%`; + const safeDecimals = Math.max(0, decimals); return `${Number(value).toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, + minimumFractionDigits: safeDecimals, + maximumFractionDigits: safeDecimals, })}%`; } diff --git a/test/lib/utils.test.ts b/test/lib/utils.test.ts index f17fbb5..e8ddee2 100644 --- a/test/lib/utils.test.ts +++ b/test/lib/utils.test.ts @@ -80,6 +80,21 @@ describe("formatAmount", () => { it("should respect custom decimals", () => { expect(formatAmount(1.123456, 2)).toBe("1.12"); }); + + it("should not throw RangeError when decimals is 0", () => { + expect(() => formatAmount(100000, 0)).not.toThrow(); + expect(formatAmount(100000, 0)).toBe("100,000"); + }); + + it("should not throw RangeError when decimals is 1", () => { + expect(() => formatAmount(1234.5, 1)).not.toThrow(); + expect(formatAmount(1234.5, 1)).toBe("1,234.5"); + }); + + it("should not throw RangeError when decimals is negative", () => { + expect(() => formatAmount(1234, -1)).not.toThrow(); + expect(formatAmount(1234, -1)).toBe("1,234"); + }); }); describe("formatCurrency", () => { @@ -110,6 +125,16 @@ describe("formatCurrency", () => { it("should not abbreviate when disabled", () => { expect(formatCurrency(5000, 2, "$", false)).toBe("$5,000.00"); }); + + it("should not throw RangeError when decimals is 0", () => { + expect(() => formatCurrency(100, 0)).not.toThrow(); + expect(formatCurrency(100, 0, "$", false)).toBe("$100"); + }); + + it("should not throw RangeError when decimals is negative", () => { + expect(() => formatCurrency(100, -1)).not.toThrow(); + expect(formatCurrency(100, -1, "$", false)).toBe("$100"); + }); }); describe("formatPercent", () => { @@ -132,6 +157,11 @@ describe("formatPercent", () => { it("should respect custom decimals", () => { expect(formatPercent(33.3333, 1)).toBe("33.3%"); }); + + it("should not throw RangeError when decimals is negative", () => { + expect(() => formatPercent(50, -1)).not.toThrow(); + expect(formatPercent(50, -1)).toBe("50%"); + }); }); describe("maskString", () => {