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
10 changes: 7 additions & 3 deletions src/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,15 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
) => {
const cutValue = getExceedValue(currentValue, compositionRef.current);

if (info.source === 'compositionEnd' && currentValue === cutValue) {
// Avoid triggering twice
// https://github.com/ant-design/ant-design/issues/46587
if (info.source === 'compositionEnd') {
// Always update internal state on compositionEnd, but skip onChange.
// The browser fires an input/change event after compositionEnd, which
// will call onInternalChange → triggerChange again and trigger onChange.
// Skipping here prevents double-firing (#46587).
setValue(cutValue);
return;
}

setValue(cutValue);

if (inputRef.current) {
Expand Down
70 changes: 20 additions & 50 deletions src/utils/commonUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,19 @@ export function hasPrefixSuffix(props: BaseInputProps | InputProps) {
return !!(props.prefix || props.suffix || props.allowClear);
}

// TODO: It's better to use `Proxy` replace the `element.value`. But we still need support IE11.
function cloneEvent<
// Create a normalized event that points target/currentTarget at the real mounted
// element instead of a detached cloneNode. This keeps document.contains(e.target)
// returning true, which third-party wrappers like react-number-format rely on.
function createNormalizedEvent<
EventType extends React.SyntheticEvent<any, any>,
Element extends HTMLInputElement | HTMLTextAreaElement,
>(event: EventType, target: Element, value: any): EventType {
// A bug report filed on WebKit's Bugzilla tracker, dating back to 2009, specifically addresses the issue of cloneNode() not copying files of <input type="file"> elements.
// As of the last update, this bug was still marked as "NEW," indicating that it might not have been resolved yet​​.
// https://bugs.webkit.org/show_bug.cgi?id=28123
const currentTarget = target.cloneNode(true) as Element;
target.value = value;

// click clear icon
const newEvent = Object.create(event, {
target: { value: currentTarget },
currentTarget: { value: currentTarget },
});

// Fill data
currentTarget.value = value;

// Fill selection. Some type like `email` not support selection
// https://github.com/ant-design/ant-design/issues/47833
if (
typeof target.selectionStart === 'number' &&
typeof target.selectionEnd === 'number'
) {
currentTarget.selectionStart = target.selectionStart;
currentTarget.selectionEnd = target.selectionEnd;
}

currentTarget.setSelectionRange = (...args) => {
target.setSelectionRange(...args);
};

return newEvent;
return Object.create(event, {
target: { value: target, enumerable: true, configurable: true },
currentTarget: { value: target, enumerable: true, configurable: true },
}) as EventType;
}

export function resolveOnChange<
Expand All @@ -59,34 +38,25 @@ export function resolveOnChange<
if (!onChange) {
return;
}
let event = e;

if (e.type === 'click') {
// Clone a new target for event.
// Avoid the following usage, the setQuery method gets the original value.
//
// const [query, setQuery] = React.useState('');
// <Input
// allowClear
// value={query}
// onChange={(e)=> {
// setQuery((prevStatus) => e.target.value);
// }}
// />

event = cloneEvent(e, target, '');

onChange(event as React.ChangeEvent<E>);
// When clearing, the click event's native target is the clear icon, not the
// input. We set target.value = '' so that e.target.value reads as the
// cleared value, then redirect target/currentTarget back to the real input.
onChange(createNormalizedEvent(e, target, '') as React.ChangeEvent<E>);
return;
}

// Trigger by composition event, this means we need force change the input value
// Trigger by composition event or exceedFormatter, this means we need force
// change the event target value
// https://github.com/ant-design/ant-design/issues/45737
// https://github.com/ant-design/ant-design/issues/46598
if (target.type !== 'file' && targetValue !== undefined) {
event = cloneEvent(e, target, targetValue);
onChange(event as React.ChangeEvent<E>);
onChange(
createNormalizedEvent(e, target, targetValue) as React.ChangeEvent<E>,
);
return;
}
onChange(event as React.ChangeEvent<E>);

onChange(e as React.ChangeEvent<E>);
}
59 changes: 59 additions & 0 deletions tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -561,3 +561,62 @@ describe('Input IME behavior', () => {
expect(onPressEnter).toHaveBeenCalled();
});
});

describe('onChange event target', () => {
// https://github.com/ant-design/ant-design/issues/46999
it('onChange target should be the real mounted input (not a detached clone) on clear', () => {
const onChange = jest.fn();
const { container } = render(
<Input
prefixCls="rc-input"
allowClear
defaultValue="hello"
onChange={onChange}
/>,
);
const input = container.querySelector('input')!;

fireEvent.click(container.querySelector('.rc-input-clear-icon')!);

expect(onChange).toHaveBeenCalled();
const event = onChange.mock.calls[0][0];
expect(event.target).toBe(input);
expect(event.currentTarget).toBe(input);
expect(document.contains(event.target)).toBe(true);
expect(event.target.value).toBe('');
});

it('onChange target should be the real mounted input for normal typing', () => {
const onChange = jest.fn();
const { container } = render(<Input onChange={onChange} />);
const input = container.querySelector('input')!;

fireEvent.change(input, { target: { value: 'test' } });

expect(onChange).toHaveBeenCalled();
const event = onChange.mock.calls[0][0];
expect(event.target).toBe(input);
expect(document.contains(event.target)).toBe(true);
});

it('onChange target should be the real mounted input when exceedFormatter is active', () => {
const onChange = jest.fn();
const { container } = render(
<Input
count={{ max: 3, exceedFormatter: (val) => val.slice(0, 3) }}
onChange={onChange}
/>,
);
const input = container.querySelector('input')!;

fireEvent.change(input, { target: { value: 'abcdef' } });

expect(onChange).toHaveBeenCalled();
const event = onChange.mock.calls[0][0];
expect(event.target).toBe(input);
expect(event.currentTarget).toBe(input);
expect(document.contains(event.target)).toBe(true);
// exceedFormatter should trim the value
expect(event.target.value).toBe('abc');
});
});
Loading