From c37d436239160192372bf9539cc10a25db42a74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Zandarin?= Date: Wed, 9 Sep 2026 10:22:18 +0200 Subject: [PATCH] fix: prevent stale normalization after controlled value updates --- src/InputNumber.tsx | 8 ++- tests/normalization.test.tsx | 98 ++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/normalization.test.tsx diff --git a/src/InputNumber.tsx b/src/InputNumber.tsx index 737aa823..d8019da8 100644 --- a/src/InputNumber.tsx +++ b/src/InputNumber.tsx @@ -693,10 +693,16 @@ const InputNumber = React.forwardRef((props, r setDecimalValue(newValue); const currentParsedValue = getMiniDecimal(mergedParser(inputValue)); + const isValueChanged = !newValue.equals(currentParsedValue); + + // Invalidate pending normalization unless the controlled value echoes the current input. + if (isValueChanged) { + inputValueUpdateRef.current += 1; + } // When user typing from `1.2` to `1.`, we should not convert to `1` immediately. // But let it go if user set `formatter` - if (!newValue.equals(currentParsedValue) || !userTypingRef.current || formatter) { + if (isValueChanged || !userTypingRef.current || formatter) { // Update value as effect setInputValue(newValue, userTypingRef.current); } diff --git a/tests/normalization.test.tsx b/tests/normalization.test.tsx new file mode 100644 index 00000000..07e93702 --- /dev/null +++ b/tests/normalization.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import InputNumber from '../src'; +import { act, fireEvent, render } from './util/wrapper'; + +describe('InputNumber input normalization', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it.each([2, 0, null, undefined, '2.5'])( + 'does not normalize stale input after value changes to %s', + (value) => { + const onChange = jest.fn(); + const onInput = jest.fn(); + const { container, rerender } = render( + , + ); + const input = container.querySelector('input'); + + fireEvent.keyDown(input, { key: '1' }); + fireEvent.change(input, { target: { value: '1。' } }); + expect(onChange).toHaveBeenCalledWith(1); + expect(onInput).toHaveBeenCalledWith('1。'); + + rerender(); + const expectedInput = value == null ? '' : String(value); + expect(input.value).toBe(expectedInput); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(input.value).toBe(expectedInput); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onInput).toHaveBeenCalledTimes(1); + }, + ); + + it.each([false, true])('preserves normalization with controlled=%s', (controlled) => { + const onChange = jest.fn(); + const onInput = jest.fn(); + const Demo = () => { + const [value, setValue] = React.useState(0); + return ( + { + onChange(nextValue); + setValue(nextValue); + }} + onInput={onInput} + /> + ); + }; + const { container } = render(); + const input = container.querySelector('input'); + + fireEvent.keyDown(input, { key: '8' }); + fireEvent.change(input, { target: { value: '8。1' } }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(input.value).toBe('8.1'); + expect(onChange.mock.calls).toEqual([[81], [8.1]]); + expect(onInput.mock.calls).toEqual([['8。1'], ['8.1']]); + }); + + it('normalizes new input after a controlled value update', () => { + const onChange = jest.fn(); + const { container, rerender } = render(); + const input = container.querySelector('input'); + + fireEvent.keyDown(input, { key: '1' }); + fireEvent.change(input, { target: { value: '1。' } }); + rerender(); + + act(() => { + jest.runOnlyPendingTimers(); + }); + onChange.mockClear(); + + fireEvent.change(input, { target: { value: '2。5' } }); + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(input.value).toBe('2.5'); + expect(onChange.mock.calls).toEqual([[25], [2.5]]); + }); +});