diff --git a/src/components/sharedComponents/BigNumberInput.test.tsx b/src/components/sharedComponents/BigNumberInput.test.tsx index 03588853..d57c0384 100644 --- a/src/components/sharedComponents/BigNumberInput.test.tsx +++ b/src/components/sharedComponents/BigNumberInput.test.tsx @@ -1,8 +1,9 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { ComponentProps } from 'react' -import { maxUint256 } from 'viem' +import { NumericFormat } from 'react-number-format' +import { maxUint256, parseUnits } from 'viem' import { describe, expect, it, vi } from 'vitest' import { BigNumberInput } from './BigNumberInput' @@ -124,3 +125,98 @@ describe('BigNumberInput', () => { expect(onError).not.toHaveBeenCalled() }) }) + +describe('BigNumberInput with renderInput (NumericFormat)', () => { + function renderWithNumericFormat( + props: Partial> & { + onChange?: (v: bigint) => void + } = {}, + initialValue = BigInt(0), + ) { + const onChange = props.onChange ?? vi.fn() + + const initialDecimals = props.decimals ?? 18 + + const makeJsx = (value: bigint, decimals = initialDecimals) => ( + + ( + handleChange(v)} + value={displayVal as string | undefined} + // biome-ignore lint/suspicious/noExplicitAny: mirrors TokenAmountField pattern + {...(restProps as any)} + /> + )} + {...props} + /> + + ) + + const { container, rerender } = render(makeJsx(initialValue)) + + return { + input: container.querySelector('input') as HTMLInputElement, + onChange, + rerender: (newValue: bigint, decimals?: number) => rerender(makeJsx(newValue, decimals)), + } + } + + it('shows empty input (placeholder) when value is 0n', () => { + const { input } = renderWithNumericFormat() + expect(input.value).toBe('') + }) + + it('formats value with thousand separators when value changes externally', async () => { + const { input, rerender } = renderWithNumericFormat() + rerender(parseUnits('1000', 18)) + await waitFor(() => { + expect(input.value).toBe('1,000') + }) + }) + + it('shows empty input after value resets to 0n', async () => { + const { input, rerender } = renderWithNumericFormat() + rerender(parseUnits('1000', 18)) + await waitFor(() => { + expect(input.value).toBe('1,000') + }) + rerender(BigInt(0)) + await waitFor(() => { + expect(input.value).toBe('') + }) + }) + + it('preserves user-typed "0" without clearing to placeholder', async () => { + const { input } = renderWithNumericFormat() + await userEvent.type(input, '0') + expect(input.value).toBe('0') + }) + + it('shows formatted initial value when mounted with non-zero value', () => { + const { input } = renderWithNumericFormat({}, parseUnits('1000', 18)) + expect(input.value).toBe('1,000') + }) + + it('reformats value when decimals change (token switch)', async () => { + const value = parseUnits('1000', 18) + const { input, rerender } = renderWithNumericFormat({}, value) + await waitFor(() => { + expect(input.value).toBe('1,000') + }) + // Same bigint but with 6 decimals produces a completely different display value + rerender(value, 6) + await waitFor(() => { + expect(input.value).toBe('1,000,000,000,000,000') + }) + }) +}) diff --git a/src/components/sharedComponents/BigNumberInput.tsx b/src/components/sharedComponents/BigNumberInput.tsx index e72a49f6..24a45438 100644 --- a/src/components/sharedComponents/BigNumberInput.tsx +++ b/src/components/sharedComponents/BigNumberInput.tsx @@ -68,8 +68,26 @@ export const BigNumberInput: FC = ({ }: BigNumberInputProps) => { const inputRef = useRef(null) const [hasError, setHasError] = useState(false) + const [displayValue, setDisplayValue] = useState(() => + value === BigInt(0) ? '' : formatUnits(value, decimals), + ) + const prevValueRef = useRef(value) + const prevDecimalsRef = useRef(decimals) + + // Sync displayValue when an external change updates value or decimals (e.g. max click, token change). + // Using render-time state update to avoid a visible flash between renders. + if (prevValueRef.current !== value || prevDecimalsRef.current !== decimals) { + prevValueRef.current = value + prevDecimalsRef.current = decimals + if (renderInput) { + setDisplayValue(value === BigInt(0) ? '' : formatUnits(value, decimals)) + } + } - // update inputValue when value changes + // DOM sync for the native input path (no renderInput). + // When renderInput is provided (e.g. NumericFormat), inputRef is not attached to the DOM + // and this effect is a no-op. External value changes for that path are handled via + // the displayValue state above. useEffect(() => { const current = inputRef.current if (!current) { @@ -101,6 +119,8 @@ export const BigNumberInput: FC = ({ const { value } = typeof event === 'string' ? { value: event } : event.currentTarget if (value === '') { + prevValueRef.current = BigInt(0) + if (renderInput) setDisplayValue('') setHasError(false) onChange(BigInt(0)) return @@ -142,6 +162,9 @@ export const BigNumberInput: FC = ({ setHasError(false) } + // Set prevValueRef before onChange so the render-time sync doesn't override the user's input. + prevValueRef.current = newValue + if (renderInput) setDisplayValue(value) onChange(newValue) } @@ -154,7 +177,7 @@ export const BigNumberInput: FC = ({ } return renderInput ? ( - renderInput({ ...inputProps, inputRef }) + renderInput({ ...inputProps, inputRef, value: displayValue }) ) : ( = ({ } const handleSetMax = () => { + setAmountError(null) setAmount(max) }