From 6f7699447b964475401f34b7c46c71a077716b10 Mon Sep 17 00:00:00 2001 From: Artur Havrylov Date: Sat, 15 Aug 2026 19:45:53 -0500 Subject: [PATCH] fix(core): track pre-edit selection via beforeinput to stop desync The pre-edit selection needed to classify each "input" event (insert vs delete, and the edited range) was cached only by a self-rescheduling setTimeout(0) poll. Browsers throttle nested zero-delay timers to a ~4ms floor, so under fast typing (or extra synchronous work per keystroke, e.g. a form library revalidating on change) the poll falls behind the real cursor position. Separately, a plain `element.value =` write - such as a controlled component re-rendering with a reformatted value - moves the native cursor to the end without firing "input" or updating the cached selection. Either path leaves the cached selection stale relative to the DOM, so the next keystroke's addedValue slice spans the wrong range and splices text back at the wrong position, producing reverted or duplicated characters. Capture the pre-edit selection from the "beforeinput" event instead, which fires synchronously right before the browser applies an edit and always reflects the real DOM selection at that instant - including a cursor already moved by a prior controlled re-render. The existing setTimeout poll is left in place as a fallback for cases beforeinput doesn't cover (autofill, or pure cursor moves with no edit). Fixes #59 --- packages/core/src/Input.ts | 29 ++++- packages/core/tests/core.test.tsx | 41 +++++++ .../tests/number-format.test.tsx | 101 +++++++++++++++++- 3 files changed, 165 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Input.ts b/packages/core/src/Input.ts index 726d98f..ac91755 100644 --- a/packages/core/src/Input.ts +++ b/packages/core/src/Input.ts @@ -7,6 +7,7 @@ const ALLOWED_TYPES = ['text', 'email', 'tel', 'search', 'url']; interface ContextValue { onFocus: (event: FocusEvent) => void; onBlur: (event: FocusEvent) => void; + onBeforeInput: (event: Event) => void; onInput: (event: Event) => void; } @@ -63,6 +64,11 @@ export default class Input { selectionEnd: 0, }; + // `beforeinput` gives the true pre-edit selection synchronously, skipping the poll. + const beforeInput = { + fresh: false, + }; + // Важно сохранить дескриптор создаваемый React const descriptor = Object.getOwnPropertyDescriptor( '_valueTracker' in element ? element : HTMLInputElement.prototype, @@ -107,6 +113,17 @@ export default class Input { timeout.id = -1; timeout.cachedId = -1; + beforeInput.fresh = false; + }; + + /** + * Handle before input + */ + const onBeforeInput = () => { + tracker.selectionStart = element.selectionStart ?? 0; + tracker.selectionEnd = element.selectionEnd ?? 0; + + beforeInput.fresh = true; }; /** @@ -114,9 +131,11 @@ export default class Input { */ const onInput = (event: Event) => { try { - // Если событие вызывается слишком часто, смена курсора может не поспеть за новым событием, - // поэтому сравниваем `timeoutId` кэшированный и текущий для избежания некорректного поведения маски - if (timeout.cachedId === timeout.id) { + if (beforeInput.fresh) { + beforeInput.fresh = false; + } else if (timeout.cachedId === timeout.id) { + // If the event fires too often, the selection poll may not have caught up yet, + // so we compare the cached and current timeoutId to avoid incorrect mask behavior. throw new SyntheticChangeError('The input selection has not been updated.'); } @@ -234,9 +253,10 @@ export default class Input { element.addEventListener('focus', onFocus); element.addEventListener('blur', onBlur); + element.addEventListener('beforeinput', onBeforeInput); element.addEventListener('input', onInput); - handlersMap.set(element, { onFocus, onBlur, onInput }); + handlersMap.set(element, { onFocus, onBlur, onBeforeInput, onInput }); }; this.unregister = (element) => { @@ -245,6 +265,7 @@ export default class Input { if (handlers !== undefined) { element.removeEventListener('focus', handlers.onFocus); element.removeEventListener('blur', handlers.onBlur); + element.removeEventListener('beforeinput', handlers.onBeforeInput); element.removeEventListener('input', handlers.onInput); handlersMap.delete(element); diff --git a/packages/core/tests/core.test.tsx b/packages/core/tests/core.test.tsx index d2d68ad..5d0e4c6 100644 --- a/packages/core/tests/core.test.tsx +++ b/packages/core/tests/core.test.tsx @@ -36,3 +36,44 @@ test('Insert with autofocus', async () => { await userEvent.type(input, '9123456789'); expect(input).toHaveValue('9123456789'); }); + +/** + * ISSUE #59: fast typing / held Backspace causes reverted or duplicated characters + */ + +afterEach(() => { + jest.useRealTimers(); +}); + +test('two input events dispatched before the selection poll ticks do not drop a keystroke', () => { + jest.useFakeTimers(); + + render(); + + const input = screen.getByTestId('testing-input'); + + expect(document.activeElement).toBe(input); + + // Bypass the library's own `value` property interceptor, the same way a real + // browser keystroke mutates the DOM value without going through any JS setter. + const nativeValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; + + const typeCharacter = (value: string, caretPosition: number) => { + // A real keystroke fires `beforeinput` synchronously, with the selection still at its + // pre-edit position, before the browser mutates the DOM value/selection. + input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText' })); + nativeValueSetter.call(input, value); + input.setSelectionRange(caretPosition, caretPosition); + input.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: false, inputType: 'insertText' })); + }; + + // First keystroke is processed normally. + typeCharacter('9', 1); + expect(input).toHaveValue('9'); + + // Second keystroke arrives before the fake-timer clock has advanced at all, so the + // pre-edit selection poll (`setTimeout(setSelection)`) has not had a chance to tick. + typeCharacter('91', 2); + + expect(input).toHaveValue('91'); +}); diff --git a/packages/number-format/tests/number-format.test.tsx b/packages/number-format/tests/number-format.test.tsx index 54051ec..fbd2d4d 100644 --- a/packages/number-format/tests/number-format.test.tsx +++ b/packages/number-format/tests/number-format.test.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import InputNumberFormat from '@react-input/number-format/InputNumberFormat'; @@ -178,3 +178,100 @@ test('Delete with selection range (4-6)', async () => { await userEvent.type(input, '{Delete}', { initialSelectionStart: 4, initialSelectionEnd: 6 }); expect(input).toHaveValue('000 014'); }); + +/** + * SELECTION TRACKING RACE + */ + +// A real controlled consumer: it echoes the digits back into `value` on every +// keystroke, the way a form library normalizing/validating on change would. +function ControlledNumberFormat(props: InputNumberFormatProps) { + const [value, setValue] = useState(''); + return ( + setValue(event.target.value.replace(/[^0-9]/g, ''))} + /> + ); +} + +const nativeValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; + +// Mutates the element the way a real keypress would (native value + caret move), +// bypassing the library's intercepted `value` setter, then dispatches the native +// `beforeinput`/`input` events the library listens for. +function typeAtCaret(input: HTMLInputElement, char: string) { + fireEvent(input, new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText' })); + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + nativeValueSetter.call(input, input.value.slice(0, start) + char + input.value.slice(end)); + input.setSelectionRange(start + 1, start + 1); + fireEvent.input(input, { inputType: 'insertText', data: char }); +} + +function backspaceAtCaret(input: HTMLInputElement) { + fireEvent( + input, + new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'deleteContentBackward' }), + ); + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + if (start === end) { + if (start === 0) { + return; + } + nativeValueSetter.call(input, input.value.slice(0, start - 1) + input.value.slice(end)); + input.setSelectionRange(start - 1, start - 1); + } else { + nativeValueSetter.call(input, input.value.slice(0, start) + input.value.slice(end)); + input.setSelectionRange(start, start); + } + fireEvent.input(input, { inputType: 'deleteContentBackward' }); +} + +test('Rapid backspace and retype does not resurrect a deleted digit', () => { + jest.useFakeTimers(); + + try { + render(); + const input = screen.getByTestId('input-number-format'); + + act(() => { + fireEvent.focus(input); + }); + + // Two keystrokes fired back-to-back, faster than the selection-tracking + // poll (a self-rescheduling zero-delay timer) can tick, followed by + // keystrokes spaced out enough for the poll to catch up. + act(() => { + typeAtCaret(input, '7'); + }); + act(() => { + backspaceAtCaret(input); + }); + act(() => { + backspaceAtCaret(input); + }); + act(() => { + jest.advanceTimersByTime(4); + typeAtCaret(input, '9'); + }); + act(() => { + jest.advanceTimersByTime(10); + backspaceAtCaret(input); + }); + act(() => { + jest.advanceTimersByTime(4); + typeAtCaret(input, '3'); + }); + + // Deleting "7" and typing "9" then deleting it and typing "3" should leave + // just "3". The stale cached selection instead lets the deleted "7" survive + // and reappear in the output alongside "3". + expect(input.value.replace(/[^0-9]/g, '')).toBe('3'); + } finally { + jest.useRealTimers(); + } +});