NumberField: Runaway onChange loop when spam-tapping increment/decrement buttons (iOS)

# NumberField: Runaway onChange loop when spam-tapping increment/decrement buttons (iOS)

**Category:** Bug

**Severity:** High

**Component:** `NumberField` (heroui-native-pro)

---

## Description

When using `NumberField` in controlled mode (`value` + `onChange`), rapidly tapping the increment (+) or decrement (-) button on iOS causes `onChange` to fire in an infinite loop. The value runs away to `maxValue` or `minValue` and does not stop even after tapping stops.

This is also reproducible in the official **HeroUI Native iOS app**.

## Environment

- heroui-native-pro: 1.0.0-beta.4 (also reproduced on beta.3)
- heroui-native: ^1.0.3
- expo: ~55.0.26
- react-native: 0.83
- Platform: iOS

## Steps to Reproduce

1. Render a `NumberField` in controlled mode with `value`, `onChange`, `step={50}`, `minValue={0}`, `maxValue={10000}`
2. On iOS, rapidly tap (spam-press) the increment (+) or decrement (-) button
3. Observe the value — it keeps incrementing/decrementing on its own and does not stop

## Expected Behavior

Each tap increments/decrements by `step` once. Rapid taps should increment once per tap. The value should stop changing when tapping stops.

## Actual Behavior

After rapid taps, `onChange` continues firing in a loop. The value runs away to `maxValue`/`minValue` without stopping.

## Root Cause (from source inspection)

Two issues in `useLongPressRepeat`:

### 1. Timer leak on rapid press-in

`onPressIn` sets `timerRef.current` to a new `setTimeout` **without clearing the previous one**. During spam-tapping, if two `onPressIn` events fire before `onPressOut` clears them (iOS touch coalescing), the first timeout is leaked — its ref was overwritten, so `clear()` can never cancel it. That orphaned timeout spawns a `setInterval` that runs indefinitely.

```js
// useLongPressRepeat.js — current code
const onPressIn = useCallback(() => {
  actionRef.current();
  timerRef.current = setTimeout(() => {       // ← overwrites without clearing
    intervalRef.current = setInterval(() => { // ← also overwrites without clearing
      actionRef.current();
    }, interval);
  }, delay);
}, [delay, interval]);
```

### 2. Stale closure in increment/decrement

The `increment`/`decrement` callbacks in `NumberFieldRoot` close over `numberValue` via `useCallback`. When the orphaned interval fires, `actionRef.current` may hold a closure with a stale `numberValue`, causing each tick to re-compute the same step and emit duplicate `onChange` calls.

## Suggested Fix

In `useLongPressRepeat`, clear existing timers before starting new ones:

```js
const onPressIn = useCallback(() => {
  if (isDisabledRef.current) return;
  clear();                    // ← clear any existing timer/interval first
  actionRef.current();
  timerRef.current = setTimeout(() => {
    intervalRef.current = setInterval(() => {
      actionRef.current();
    }, interval);
  }, delay);
}, [delay, interval, clear]);
```

Additionally, consider using a ref for `numberValue` inside `increment`/`decrement` instead of closing over it, so interval ticks always read the latest value.

## Minimal Repro

```tsx
import { useCallback, useMemo, useState } from "react";
import { View } from "react-native";
import { Text } from "heroui-native";
import { NumberField } from "heroui-native-pro";

export default function NumberFieldRepro() {
  const [amount, setAmount] = useState(0);
  const [changeCount, setChangeCount] = useState(0);

  const formatOptions = useMemo<Intl.NumberFormatOptions>(
    () => ({ style: "currency", currency: "USD", minimumFractionDigits: 0, maximumFractionDigits: 0 }),
    [],
  );

  const handleChange = useCallback((newValue: number) => {
    if (isNaN(newValue)) return;
    console.log(`[NumberField onChange] newValue=${newValue}`);
    setChangeCount((c) => c + 1);
    setAmount(newValue);
  }, []);

  return (
    <View className="flex-1 justify-center px-5 gap-6">
      <Text.Paragraph>onChange fired {changeCount} times — spam-tap +/- to reproduce</Text.Paragraph>
      <NumberField value={amount} onChange={handleChange} minValue={0} maxValue={10000} step={50} formatOptions={formatOptions}>
        <NumberField.Group>
          <NumberField.DecrementButton />
          <NumberField.Input placeholder="$0" className="text-center" />
          <NumberField.IncrementButton />
        </NumberField.Group>
      </NumberField>
    </View>
  );
}
```

## Workaround (consumer-side)

Guard `onChange` with a tolerance check to suppress duplicate state updates:

```ts
const handleChange = useCallback((v: number) => {
  if (isNaN(v)) return;
  setAmount(prev => Math.abs(prev - v) < 0.01 ? prev : v);
}, []);
```

Please authenticate to join the conversation.

Upvoters
Status

In Review

Board

🐛 Bug Reports

Date

1 day ago

Author

Stefan Kudla

Subscribe to post

Get notified by email when there are changes.