Skip to content

Commit 9904c82

Browse files
afc163claude
andcommitted
fix: eliminate cloneNode to use real mounted element as event target
Replace cloneEvent (which uses cloneNode creating a detached DOM node) with createNormalizedEvent that sets target.value directly and points target/currentTarget at the real mounted input element. This fixes react-number-format customInput compatibility (#46999) where document.contains(e.target) returns false for detached clones. Also revert triggerChange to use compositionEnd early-return with setValue to prevent double-firing (#46587), keeping both fixes working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8b74fd6 commit 9904c82

3 files changed

Lines changed: 85 additions & 51 deletions

File tree

src/Input.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,18 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
117117
inputRef.current?.selectionEnd || 0,
118118
]);
119119
}
120+
} else if (info.source === 'compositionEnd') {
121+
// Always update internal state on compositionEnd, but skip onChange.
122+
// The browser fires an input/change event after compositionEnd, which
123+
// will call onInternalChange → triggerChange again and trigger onChange.
124+
// Skipping here prevents double-firing (#46587).
125+
setValue(cutValue);
126+
return;
120127
}
121128

122-
// Always update the value state
123129
setValue(cutValue);
124130

125-
// Only trigger onChange if value has actually changed
126-
// This prevents double-firing (issue #46587) while still allowing
127-
// external wrappers like react-number-format to work (issue #46999)
128-
if (inputRef.current && cutValue !== formatValue) {
131+
if (inputRef.current) {
129132
resolveOnChange(inputRef.current, e, onChange, cutValue);
130133
}
131134
};

src/utils/commonUtils.ts

Lines changed: 18 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,19 @@ export function hasPrefixSuffix(props: BaseInputProps | InputProps) {
99
return !!(props.prefix || props.suffix || props.allowClear);
1010
}
1111

12-
// TODO: It's better to use `Proxy` replace the `element.value`. But we still need support IE11.
13-
function cloneEvent<
12+
// Create a normalized event that points target/currentTarget at the real mounted
13+
// element instead of a detached cloneNode. This keeps document.contains(e.target)
14+
// returning true, which third-party wrappers like react-number-format rely on.
15+
function createNormalizedEvent<
1416
EventType extends React.SyntheticEvent<any, any>,
1517
Element extends HTMLInputElement | HTMLTextAreaElement,
1618
>(event: EventType, target: Element, value: any): EventType {
17-
// 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.
18-
// As of the last update, this bug was still marked as "NEW," indicating that it might not have been resolved yet​​.
19-
// https://bugs.webkit.org/show_bug.cgi?id=28123
20-
const currentTarget = target.cloneNode(true) as Element;
19+
target.value = value;
2120

22-
// click clear icon
23-
const newEvent = Object.create(event, {
24-
target: { value: currentTarget },
25-
currentTarget: { value: currentTarget },
26-
});
27-
28-
// Fill data
29-
currentTarget.value = value;
30-
31-
// Fill selection. Some type like `email` not support selection
32-
// https://github.com/ant-design/ant-design/issues/47833
33-
if (
34-
typeof target.selectionStart === 'number' &&
35-
typeof target.selectionEnd === 'number'
36-
) {
37-
currentTarget.selectionStart = target.selectionStart;
38-
currentTarget.selectionEnd = target.selectionEnd;
39-
}
40-
41-
return newEvent;
21+
return Object.create(event, {
22+
target: { value: target, enumerable: true, configurable: true },
23+
currentTarget: { value: target, enumerable: true, configurable: true },
24+
}) as EventType;
4225
}
4326

4427
export function resolveOnChange<
@@ -55,36 +38,25 @@ export function resolveOnChange<
5538
if (!onChange) {
5639
return;
5740
}
58-
let event = e;
5941

6042
if (e.type === 'click') {
61-
// Clone a new target for event.
62-
// Avoid the following usage, the setQuery method gets the original value.
63-
//
64-
// const [query, setQuery] = React.useState('');
65-
// <Input
66-
// allowClear
67-
// value={query}
68-
// onChange={(e)=> {
69-
// setQuery((prevStatus) => e.target.value);
70-
// }}
71-
// />
72-
73-
event = cloneEvent(e, target, '');
74-
75-
onChange(event as React.ChangeEvent<E>);
43+
// When clearing, the click event's native target is the clear icon, not the
44+
// input. We set target.value = '' so that e.target.value reads as the
45+
// cleared value, then redirect target/currentTarget back to the real input.
46+
onChange(createNormalizedEvent(e, target, ''));
7647
return;
7748
}
7849

79-
// Trigger by composition event, this means we need force change the input value
50+
// Trigger by composition event or exceedFormatter, this means we need force
51+
// change the event target value
8052
// https://github.com/ant-design/ant-design/issues/45737
8153
// https://github.com/ant-design/ant-design/issues/46598
8254
if (target.type !== 'file' && targetValue !== undefined) {
83-
event = cloneEvent(e, target, targetValue);
84-
onChange(event as React.ChangeEvent<E>);
55+
onChange(createNormalizedEvent(e, target, targetValue));
8556
return;
8657
}
87-
onChange(event as React.ChangeEvent<E>);
58+
59+
onChange(e as React.ChangeEvent<E>);
8860
}
8961

9062
export interface InputFocusOptions extends FocusOptions {

tests/index.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,3 +397,62 @@ describe('resolveChange should work', () => {
397397
fireEvent.compositionEnd(container.querySelector('textarea')!);
398398
expect(onChange).toHaveBeenCalled();
399399
});
400+
401+
describe('onChange event target', () => {
402+
// https://github.com/ant-design/ant-design/issues/46999
403+
it('onChange target should be the real mounted input (not a detached clone) on clear', () => {
404+
const onChange = jest.fn();
405+
const { container } = render(
406+
<Input
407+
prefixCls="rc-input"
408+
allowClear
409+
defaultValue="hello"
410+
onChange={onChange}
411+
/>,
412+
);
413+
const input = container.querySelector('input')!;
414+
415+
fireEvent.click(container.querySelector('.rc-input-clear-icon')!);
416+
417+
expect(onChange).toHaveBeenCalled();
418+
const event = onChange.mock.calls[0][0];
419+
expect(event.target).toBe(input);
420+
expect(event.currentTarget).toBe(input);
421+
expect(document.contains(event.target)).toBe(true);
422+
expect(event.target.value).toBe('');
423+
});
424+
425+
it('onChange target should be the real mounted input for normal typing', () => {
426+
const onChange = jest.fn();
427+
const { container } = render(<Input onChange={onChange} />);
428+
const input = container.querySelector('input')!;
429+
430+
fireEvent.change(input, { target: { value: 'test' } });
431+
432+
expect(onChange).toHaveBeenCalled();
433+
const event = onChange.mock.calls[0][0];
434+
expect(event.target).toBe(input);
435+
expect(document.contains(event.target)).toBe(true);
436+
});
437+
438+
it('onChange target should be the real mounted input when exceedFormatter is active', () => {
439+
const onChange = jest.fn();
440+
const { container } = render(
441+
<Input
442+
count={{ max: 3, exceedFormatter: (val) => val.slice(0, 3) }}
443+
onChange={onChange}
444+
/>,
445+
);
446+
const input = container.querySelector('input')!;
447+
448+
fireEvent.change(input, { target: { value: 'abcdef' } });
449+
450+
expect(onChange).toHaveBeenCalled();
451+
const event = onChange.mock.calls[0][0];
452+
expect(event.target).toBe(input);
453+
expect(event.currentTarget).toBe(input);
454+
expect(document.contains(event.target)).toBe(true);
455+
// exceedFormatter should trim the value
456+
expect(event.target.value).toBe('abc');
457+
});
458+
});

0 commit comments

Comments
 (0)