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(); + } +});