Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions packages/core/src/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -63,6 +64,11 @@ export default class Input<T = unknown> {
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,
Expand Down Expand Up @@ -107,16 +113,29 @@ export default class Input<T = unknown> {

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;
};

/**
* Handle 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.');
}

Expand Down Expand Up @@ -234,9 +253,10 @@ export default class Input<T = unknown> {

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) => {
Expand All @@ -245,6 +265,7 @@ export default class Input<T = unknown> {
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);
Expand Down
41 changes: 41 additions & 0 deletions packages/core/tests/core.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Input />);

const input = screen.getByTestId<HTMLInputElement>('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');
});
101 changes: 99 additions & 2 deletions packages/number-format/tests/number-format.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<InputNumberFormat
{...props}
data-testid="input-number-format"
value={value}
onChange={(event) => 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(<ControlledNumberFormat locales="en-US" />);
const input = screen.getByTestId<HTMLInputElement>('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();
}
});