Skip to content

Commit c8a502c

Browse files
Merge pull request #1528 from curvefi/fix/lti-decimals
fix: various fixes and improvements to numeric text field and large token input
2 parents 7114f4b + 31a4e25 commit c8a502c

File tree

13 files changed

+280
-119
lines changed

13 files changed

+280
-119
lines changed

apps/main/src/llamalend/features/market-list/filters/RangeSliderFilter.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ export const RangeSliderFilter = <T,>({
5151
}, [columnFilters, id, maxValue, defaultMinimum])
5252
const isMobile = useIsMobile()
5353

54-
const [range, setRange] = useUniqueDebounce(
54+
const [range, setRange] = useUniqueDebounce({
5555
defaultValue,
56-
useCallback(
56+
callback: useCallback(
5757
(newRange: NumberRange) =>
5858
setColumnFilter(
5959
id,
@@ -63,7 +63,7 @@ export const RangeSliderFilter = <T,>({
6363
),
6464
[defaultMinimum, defaultValue, id, maxValue, setColumnFilter],
6565
),
66-
)
66+
})
6767

6868
const onChange = useCallback<OnSliderChange>((_, newRange) => setRange(newRange as NumberRange), [setRange])
6969

packages/curve-ui-kit/src/hooks/useDebounce.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Duration } from '@ui-kit/themes/design/0_primitives'
77
* @param initialValue - The initial value to use
88
* @param debounceMs - The debounce period in milliseconds
99
* @param callback - Callback function that is called after the debounce period
10-
* @returns A tuple containing the current value and a setter function
10+
* @returns A triple containing the current value, a setter function and a cancel function
1111
*
1212
* @example
1313
* ```tsx
@@ -28,7 +28,7 @@ import { Duration } from '@ui-kit/themes/design/0_primitives'
2828
*
2929
* // With a controlled component
3030
* // The hook will update its internal value when initialValue changes
31-
* const [debouncedValue, setDebouncedValue] = useDebounce(externalValue, 200, handleChange);
31+
* const [debouncedValue, setDebouncedValue, cancel] = useDebounce(externalValue, 200, handleChange);
3232
*/
3333
export function useDebounce<T>(initialValue: T, debounceMs: number, callback: (value: T) => void) {
3434
const [value, setValue] = useState<T>(initialValue)
@@ -49,26 +49,25 @@ export function useDebounce<T>(initialValue: T, debounceMs: number, callback: (v
4949
[],
5050
)
5151

52+
// Clear any existing timer
53+
const cancel = useCallback(() => timerRef.current && clearTimeout(timerRef.current), [])
54+
5255
// Sets the internal value, but calls the callback after a delay unless retriggered again.
5356
const setDebouncedValue = useCallback(
5457
(newValue: T) => {
5558
setValue(newValue)
56-
57-
// Clear any existing timer
58-
if (timerRef.current !== null) {
59-
clearTimeout(timerRef.current)
60-
}
59+
cancel()
6160

6261
// Initiate a new timer
6362
timerRef.current = window.setTimeout(() => {
6463
callback(newValue)
6564
timerRef.current = null
6665
}, debounceMs)
6766
},
68-
[callback, debounceMs],
67+
[callback, cancel, debounceMs],
6968
)
7069

71-
return [value, setDebouncedValue] as const
70+
return [value, setDebouncedValue, cancel] as const
7271
}
7372

7473
/**
@@ -93,9 +92,26 @@ export function useDebouncedValue<T>(
9392
const SearchDebounceMs = 166 // 10 frames at 60fps
9493

9594
/**
96-
* A hook that debounces a search value and calls a callback when the debounce period has elapsed.
95+
* A hook that debounces a value and only calls the callback when the value has actually changed.
96+
* This prevents unnecessary callback executions when the debounced value hasn't changed.
97+
*
98+
* @param defaultValue - The initial value to use
99+
* @param callback - Function called when the debounced value changes
100+
* @param debounceMs - The debounce period in milliseconds (default: 166ms)
101+
* @param equals - Optional custom equality function to compare values
102+
* @returns A tuple containing the current value and a setter function
97103
*/
98-
export function useUniqueDebounce<T>(defaultValue: T, callback: (value: T) => void, debounceMs = SearchDebounceMs) {
104+
export function useUniqueDebounce<T>({
105+
defaultValue,
106+
callback,
107+
debounceMs = SearchDebounceMs,
108+
equals,
109+
}: {
110+
defaultValue: T
111+
callback: (value: T) => void
112+
debounceMs?: number
113+
equals?: (a: T, b: T) => boolean
114+
}) {
99115
const lastValue = useRef(defaultValue)
100116

101117
/**
@@ -113,16 +129,14 @@ export function useUniqueDebounce<T>(defaultValue: T, callback: (value: T) => vo
113129

114130
const debounceCallback = useCallback(
115131
(value: T) => {
116-
if (typeof value === 'string') {
117-
value = value.trim() as unknown as T
118-
}
119-
if (value !== lastValue.current) {
132+
const isEqual = equals ? equals(value, lastValue.current) : value === lastValue.current
133+
if (!isEqual) {
120134
lastValue.current = value
121135
callback(value)
122136
}
123137
},
124-
[callback],
138+
[callback, equals],
125139
)
126-
const [search, setSearch] = useDebounce(defaultValue, debounceMs, debounceCallback)
127-
return [search, setSearch] as const
140+
141+
return useDebounce(defaultValue, debounceMs, debounceCallback)
128142
}

packages/curve-ui-kit/src/shared/ui/LargeTokenInput.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import Box from '@mui/material/Box'
44
import Chip from '@mui/material/Chip'
55
import Stack from '@mui/material/Stack'
66
import Typography from '@mui/material/Typography'
7-
import { useDebounce } from '@ui-kit/hooks/useDebounce'
7+
import { useUniqueDebounce } from '@ui-kit/hooks/useDebounce'
88
import { t } from '@ui-kit/lib/i18n'
99
import { Duration, TransitionFunction } from '@ui-kit/themes/design/0_primitives'
1010
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
11-
import { formatNumber, type Decimal } from '@ui-kit/utils'
11+
import { decimal, formatNumber, type Decimal } from '@ui-kit/utils'
1212
import { Balance, type Props as BalanceProps } from './Balance'
1313
import { NumericTextField } from './NumericTextField'
1414
import { TradingSlider } from './TradingSlider'
@@ -70,7 +70,7 @@ type BalanceTextFieldProps = {
7070
maxBalance?: Decimal
7171
isError: boolean
7272
disabled?: boolean
73-
onCommit: (balance: Decimal | undefined) => void
73+
onCommit: (balance: string | undefined) => void
7474
name: string
7575
}
7676

@@ -245,6 +245,9 @@ const calculateNewPercentage = (newBalance: Decimal, max: Decimal) =>
245245
.toFixed(2)
246246
.replace(/\.?0+$/, '') as Decimal
247247

248+
/** Converts two decimals to BigNumber for comparison. Undefined is considered zero. */
249+
const bigNumEquals = (a?: Decimal, b?: Decimal) => new BigNumber(a ?? 0).isEqualTo(b ?? 0)
250+
248251
export const LargeTokenInput = ({
249252
ref,
250253
tokenSelector,
@@ -261,7 +264,13 @@ export const LargeTokenInput = ({
261264
testId,
262265
}: Props) => {
263266
const [percentage, setPercentage] = useState<Decimal | undefined>(undefined)
264-
const [balance, setBalance] = useDebounce(externalBalance, Duration.FormDebounce, onBalance)
267+
const [balance, setBalance, cancelSetBalance] = useUniqueDebounce({
268+
defaultValue: externalBalance,
269+
callback: onBalance,
270+
debounceMs: Duration.FormDebounce,
271+
// We don't want to trigger onBalance if the value is effectively the same, e.g. "0.0" and "0.00"
272+
equals: bigNumEquals,
273+
})
265274

266275
const showSlider = !!maxBalance?.showSlider
267276
const showWalletBalance = maxBalance && maxBalance.showBalance !== false
@@ -281,14 +290,21 @@ export const LargeTokenInput = ({
281290
)
282291

283292
const handleBalanceChange = useCallback(
284-
(newBalance: Decimal | undefined) => {
285-
if (newBalance == null) return
286-
setBalance(newBalance)
293+
(newBalance: string | undefined) => {
294+
// In case the input is somehow invalid, although we do our best to sanitize it in NumericTextField,
295+
// we cancel the debounce such that the input won't reset while still typing.
296+
const decimalBalance = decimal(newBalance)
297+
if (decimalBalance == null) {
298+
cancelSetBalance()
299+
return
300+
}
301+
302+
setBalance(decimalBalance)
287303
setPercentage(
288-
maxBalance?.balance && newBalance ? calculateNewPercentage(newBalance, maxBalance.balance) : undefined,
304+
maxBalance?.balance && newBalance ? calculateNewPercentage(decimalBalance, maxBalance.balance) : undefined,
289305
)
290306
},
291-
[maxBalance?.balance, setBalance],
307+
[maxBalance?.balance, setBalance, cancelSetBalance],
292308
)
293309

294310
const handleChip = useCallback(

packages/curve-ui-kit/src/shared/ui/NumericTextField.tsx

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@ import { type Decimal } from '@ui-kit/utils'
1111
*
1212
* @param value - The new input value to validate
1313
* @param current - The current input value to fall back to if validation fails
14-
* @param allowNegative - Whether to allow negative numbers
1514
* @returns The validated and normalized input value, or the current value if invalid
1615
*/
17-
const sanitize = (value: string, current: string, allowNegative: boolean): string => {
16+
const sanitize = (value: string, current: string): string => {
1817
const normalizedValue = value.replace(/,/g, '.')
1918

2019
// If more than one decimal point, return the current value (ignore the change)
@@ -28,13 +27,8 @@ const sanitize = (value: string, current: string, allowNegative: boolean): strin
2827
return current
2928
}
3029

31-
// If negative numbers are not allowed and value starts with minus, ignore the change
32-
if (!allowNegative && minusIndex === 0) {
33-
return current
34-
}
35-
3630
// Check if it contains only valid characters (numbers, optional minus at start if allowed, and one optional decimal)
37-
const pattern = allowNegative ? /^-?[0-9]*\.?[0-9]*([eE][-+]?[0-9]+)?$/ : /^[0-9]*\.?[0-9]*([eE][-+]?[0-9]+)?$/
31+
const pattern = /^-?[0-9]*\.?[0-9]*([eE][-+]?[0-9]+)?$/
3832
return pattern.test(normalizedValue) ? normalizedValue : current
3933
}
4034

@@ -72,8 +66,8 @@ type NumericTextFieldProps = Omit<TextFieldProps, 'type' | 'value' | 'onChange'
7266
min?: Decimal
7367
/** Maximum allowed value (default: Infinity) */
7468
max?: Decimal
75-
/** Callback fired when the numeric value changes */
76-
onChange?: (value: Decimal | undefined) => void
69+
/** Callback fired when the numeric value changes, can be a temporary non decimal value like "5." or "-" */
70+
onChange?: (value: string | undefined) => void
7771
/** Callback fired when the numeric is being submitted */
7872
onBlur?: (value: Decimal | undefined) => void
7973
}
@@ -82,27 +76,14 @@ export const NumericTextField = ({ value, min, max, onChange, onBlur, onFocus, .
8276
// Internal value that might be incomplete, like "4.".
8377
const [inputValue, setInputValue] = useState(getDisplayValue(value))
8478

85-
const [lastChangeValue, setLastChangeValue] = useState(value)
79+
const [lastChangeValue, setLastChangeValue] = useState<string | undefined>(value)
8680
const [lastBlurValue, setLastBlurValue] = useState(value)
8781

8882
// Update input value when value changes externally
8983
useEffect(() => {
9084
setInputValue(getDisplayValue(value))
9185
}, [value])
9286

93-
/**
94-
* Converts a string input to a numeric value with optional clamping.
95-
*
96-
* @param validatedValue - The sanitized string input
97-
* @param shouldClamp - Whether to apply min/max bounds
98-
* @returns Numeric value, undefined for empty/invalid input
99-
*/
100-
const parseAndClamp = (validatedValue: string, { shouldClamp = false }: { shouldClamp?: boolean }) => {
101-
if (validatedValue === '') return undefined
102-
const result = shouldClamp ? clamp(validatedValue, min, max) : new BigNumber(validatedValue)
103-
return result.toString() as Decimal
104-
}
105-
10687
return (
10788
<TextField
10889
{...props}
@@ -120,19 +101,17 @@ export const NumericTextField = ({ value, min, max, onChange, onBlur, onFocus, .
120101
onFocus?.(e)
121102
}}
122103
onChange={(e) => {
123-
const sanitizedValue = sanitize(e.target.value, inputValue, min == null || +min < 0)
104+
const sanitizedValue = sanitize(e.target.value, inputValue)
124105
setInputValue(sanitizedValue)
125-
126-
const changedValue = parseAndClamp(sanitizedValue, { shouldClamp: false })
127-
128-
if (changedValue !== lastChangeValue) {
129-
onChange?.(changedValue)
130-
setLastChangeValue(changedValue)
131-
}
106+
onChange?.(sanitizedValue)
107+
setLastChangeValue(sanitizedValue)
132108
}}
133109
onBlur={() => {
134-
// Replace a sole minus with just empty input as it's not really valid.
135-
const finalValue = parseAndClamp(inputValue === '-' ? '' : inputValue, { shouldClamp: true })
110+
// Replace a sole invalid values with just empty input as they're not really valid.
111+
const invalidValues = ['-', '.', ',', '']
112+
const finalValue = invalidValues.includes(inputValue)
113+
? undefined
114+
: (clamp(inputValue, min, max).toString() as Decimal)
136115
setInputValue(getDisplayValue(finalValue))
137116

138117
// Also emit the changed event, because due to clamping and such the final value

packages/curve-ui-kit/src/shared/ui/SearchField.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export type SearchFieldProps = TextFieldProps & {
1212
inputRef?: RefObject<HTMLInputElement | null>
1313
}
1414

15+
/** Compares two strings, ignoring leading and trailing whitespace. */
16+
const searchFieldEquals = (a: string, b: string) => a.trim() === b.trim()
17+
1518
/**
1619
* Search field with debounced search. It is cleared and focused when clicking the close button.
1720
*/
@@ -23,7 +26,11 @@ export const SearchField = ({
2326
inputRef,
2427
...props
2528
}: SearchFieldProps) => {
26-
const [search, setSearch] = useUniqueDebounce<string>(value, onSearch)
29+
const [search, setSearch] = useUniqueDebounce<string>({
30+
defaultValue: value,
31+
callback: onSearch,
32+
equals: searchFieldEquals,
33+
})
2734
const localInputRef = useRef<HTMLInputElement | null>(null)
2835
const ref = inputRef || localInputRef
2936
const resetSearch = useCallback(() => {

packages/curve-ui-kit/src/shared/ui/TradingSlider.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Stack from '@mui/material/Stack'
44
import Typography from '@mui/material/Typography'
55
import { CLASS_BORDERLESS, SLIDER_BACKGROUND_VAR } from '@ui-kit/themes/components/slider'
66
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
7-
import { type Decimal } from '@ui-kit/utils'
7+
import { decimal, type Decimal } from '@ui-kit/utils'
88
import { NumericTextField } from './NumericTextField'
99

1010
const { Spacing } = SizesAndSpaces
@@ -59,7 +59,7 @@ export const TradingSlider = ({ percentage, onChange, onCommit, step = 1, textAl
5959
value={percentage}
6060
min="0"
6161
max="100"
62-
onChange={(newPercentage) => onChange?.(newPercentage)}
62+
onChange={(newPercentage) => onChange?.(decimal(newPercentage))}
6363
onBlur={(newPercentage) => onCommit?.(newPercentage)}
6464
disabled={disabled}
6565
slotProps={{
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { decimal } from './decimal'
3+
4+
describe('decimal', () => {
5+
it('handles basic, normal numbers', () => {
6+
expect(decimal('1')).toBe('1')
7+
expect(decimal('3.14')).toBe('3.14')
8+
expect(decimal('-4.20')).toBe('-4.20')
9+
expect(decimal('1.55e20')).toBe('1.55e20')
10+
expect(decimal('1e-10')).toBe('1e-10')
11+
})
12+
13+
it('handles zero and negative zero', () => {
14+
expect(decimal('0')).toBe('0')
15+
expect(decimal('0.00')).toBe('0.00')
16+
expect(decimal('-0')).toBe('-0')
17+
})
18+
19+
it('handles incomplete numbers', () => {
20+
expect(decimal('5.')).toBe('5.') // trailing decimal still converts fine to numbers
21+
})
22+
23+
it.each(['Infinity', '-Infinity', undefined, null, '', '?', '-', NaN])('handles edge cases', (invalidCharacter) => {
24+
expect(decimal(invalidCharacter as any)).toBe(undefined)
25+
})
26+
27+
it('handles large and small numbers', () => {
28+
// Would be "Infinity" or with native number type
29+
const veryLargeNumber =
30+
'123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890.1'
31+
32+
// Would be "0" or with native number type
33+
const verySmallNumber =
34+
'0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000123456789'
35+
36+
expect(decimal(veryLargeNumber)).toBe(veryLargeNumber)
37+
expect(decimal(verySmallNumber)).toBe(verySmallNumber)
38+
})
39+
40+
it('returns undefined for non-numeric strings', () => {
41+
expect(decimal('abc')).toBe(undefined)
42+
expect(decimal('12.34.56')).toBe(undefined)
43+
expect(decimal('12a34')).toBe(undefined)
44+
})
45+
})

0 commit comments

Comments
 (0)