Skip to content

Commit da4781b

Browse files
authored
fix: Should not trigger onChange multiple times (#603)
* fix: Should not trigger onChange multiple times * test: Add test case * fix lint
1 parent 65c2e47 commit da4781b

File tree

5 files changed

+98
-46
lines changed

5 files changed

+98
-46
lines changed

examples/custom-icon.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable no-console */
1+
/* eslint-disable no-console, max-classes-per-file */
22
import React from 'react';
33
import Select, { Option } from '../src';
44
import '../assets/index.less';
@@ -22,7 +22,7 @@ const clearPath =
2222
' 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h618c35.3 0 64-' +
2323
'28.7 64-64V306c0-35.3-28.7-64-64-64z';
2424

25-
const menuItemSelectedIcon = props => {
25+
const menuItemSelectedIcon = (props) => {
2626
const { ...p } = props;
2727
return <span style={{ position: 'absolute', right: 0 }}>{p.isSelected ? '🌹' : '☑️'}</span>;
2828
};
@@ -33,7 +33,7 @@ const singleItemIcon = (
3333
</span>
3434
);
3535

36-
const getSvg = path => (
36+
const getSvg = (path) => (
3737
<i>
3838
<svg
3939
viewBox="0 0 1024 1024"
@@ -60,7 +60,7 @@ class CustomIconComponent extends React.Component {
6060
});
6161
};
6262

63-
onKeyDown = e => {
63+
onKeyDown = (e) => {
6464
const { value } = this.state;
6565
if (e.keyCode === 13) {
6666
console.log('onEnter', value);
@@ -158,7 +158,7 @@ class Test extends React.Component {
158158
console.log(args);
159159
};
160160

161-
useAnim = e => {
161+
useAnim = (e) => {
162162
this.setState({
163163
useAnim: e.target.checked,
164164
});

src/Selector/index.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
import * as React from 'react';
1212
import { useRef } from 'react';
1313
import KeyCode from 'rc-util/lib/KeyCode';
14-
import { ScrollTo } from 'rc-virtual-list/lib/List';
14+
import type { ScrollTo } from 'rc-virtual-list/lib/List';
1515
import MultipleSelector from './MultipleSelector';
1616
import SingleSelector from './SingleSelector';
17-
import { LabelValueType, RawValueType, CustomTagProps } from '../interface/generator';
18-
import { RenderNode, Mode } from '../interface';
17+
import type { LabelValueType, RawValueType, CustomTagProps } from '../interface/generator';
18+
import type { RenderNode, Mode } from '../interface';
1919
import useLock from '../hooks/useLock';
2020

2121
export interface InnerSelectorProps {
@@ -129,7 +129,7 @@ const Selector: React.RefForwardingComponent<RefSelectorProps, SelectorProps> =
129129
// ====================== Input ======================
130130
const [getInputMouseDown, setInputMouseDown] = useLock(0);
131131

132-
const onInternalInputKeyDown: React.KeyboardEventHandler<HTMLInputElement> = event => {
132+
const onInternalInputKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (event) => {
133133
const { which } = event;
134134

135135
if (which === KeyCode.UP || which === KeyCode.DOWN) {
@@ -172,12 +172,16 @@ const Selector: React.RefForwardingComponent<RefSelectorProps, SelectorProps> =
172172
compositionStatusRef.current = true;
173173
};
174174

175-
const onInputCompositionEnd: React.CompositionEventHandler<HTMLInputElement> = e => {
175+
const onInputCompositionEnd: React.CompositionEventHandler<HTMLInputElement> = (e) => {
176176
compositionStatusRef.current = false;
177-
triggerOnSearch((e.target as HTMLInputElement).value);
177+
178+
// Trigger search again to support `tokenSeparators` with typewriting
179+
if (mode !== 'combobox') {
180+
triggerOnSearch((e.target as HTMLInputElement).value);
181+
}
178182
};
179183

180-
const onInputChange: React.ChangeEventHandler<HTMLInputElement> = event => {
184+
const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
181185
let {
182186
target: { value },
183187
} = event;
@@ -197,7 +201,7 @@ const Selector: React.RefForwardingComponent<RefSelectorProps, SelectorProps> =
197201
triggerOnSearch(value);
198202
};
199203

200-
const onInputPaste: React.ClipboardEventHandler = e => {
204+
const onInputPaste: React.ClipboardEventHandler = (e) => {
201205
const { clipboardData } = e;
202206
const value = clipboardData.getData('text');
203207

@@ -218,7 +222,7 @@ const Selector: React.RefForwardingComponent<RefSelectorProps, SelectorProps> =
218222
}
219223
};
220224

221-
const onMouseDown: React.MouseEventHandler<HTMLElement> = event => {
225+
const onMouseDown: React.MouseEventHandler<HTMLElement> = (event) => {
222226
const inputMouseDown = getInputMouseDown();
223227
if (event.target !== inputRef.current && !inputMouseDown) {
224228
event.preventDefault();

src/generate.tsx

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import { useState, useRef, useEffect, useMemo } from 'react';
1212
import KeyCode from 'rc-util/lib/KeyCode';
1313
import classNames from 'classnames';
1414
import useMergedState from 'rc-util/lib/hooks/useMergedState';
15-
import { ScrollTo } from 'rc-virtual-list/lib/List';
16-
import Selector, { RefSelectorProps } from './Selector';
17-
import SelectTrigger, { RefTriggerProps } from './SelectTrigger';
18-
import { RenderNode, Mode, RenderDOMFunc, OnActiveValue } from './interface';
19-
import {
15+
import type { ScrollTo } from 'rc-virtual-list/lib/List';
16+
import type { RefSelectorProps } from './Selector';
17+
import Selector from './Selector';
18+
import type { RefTriggerProps } from './SelectTrigger';
19+
import SelectTrigger from './SelectTrigger';
20+
import type { RenderNode, Mode, RenderDOMFunc, OnActiveValue } from './interface';
21+
import type {
2022
GetLabeledValue,
2123
FilterOptions,
2224
FilterFunc,
@@ -29,11 +31,12 @@ import {
2931
FlattenOptionsType,
3032
SingleType,
3133
OnClear,
32-
INTERNAL_PROPS_MARK,
3334
SelectSource,
34-
CustomTagProps,
35+
CustomTagProps} from './interface/generator';
36+
import {
37+
INTERNAL_PROPS_MARK
3538
} from './interface/generator';
36-
import { OptionListProps, RefOptionListProps } from './OptionList';
39+
import type { OptionListProps, RefOptionListProps } from './OptionList';
3740
import { toInnerValue, toOuterValues, removeLastEnabledValue, getUUID } from './utils/commonUtil';
3841
import TransBtn from './TransBtn';
3942
import useLock from './hooks/useLock';
@@ -59,7 +62,7 @@ const DEFAULT_OMIT_PROPS = [
5962
export interface RefSelectProps {
6063
focus: () => void;
6164
blur: () => void;
62-
scrollTo?: ScrollTo,
65+
scrollTo?: ScrollTo;
6366
}
6467

6568
export interface SelectProps<OptionsType extends object[], ValueType> extends React.AriaAttributes {
@@ -194,7 +197,7 @@ export interface GenerateConfig<OptionsType extends object[]> {
194197
getLabeledValue: GetLabeledValue<FlattenOptionsType<OptionsType>>;
195198
filterOptions: FilterOptions<OptionsType>;
196199
findValueOption: // Need still support legacy ts api
197-
| ((values: RawValueType[], options: FlattenOptionsType<OptionsType>) => OptionsType)
200+
| ((values: RawValueType[], options: FlattenOptionsType<OptionsType>) => OptionsType)
198201
// New API add prevValueOptions support
199202
| ((
200203
values: RawValueType[],
@@ -327,7 +330,7 @@ export default function generateSelector<
327330
const useInternalProps = internalProps.mark === INTERNAL_PROPS_MARK;
328331

329332
const domProps = omitDOMProps ? omitDOMProps(restProps) : restProps;
330-
DEFAULT_OMIT_PROPS.forEach(prop => {
333+
DEFAULT_OMIT_PROPS.forEach((prop) => {
331334
delete domProps[prop];
332335
});
333336

@@ -337,7 +340,8 @@ export default function generateSelector<
337340
const listRef = useRef<RefOptionListProps>(null);
338341

339342
const tokenWithEnter = useMemo(
340-
() => (tokenSeparators || []).some(tokenSeparator => ['\n', '\r\n'].includes(tokenSeparator)),
343+
() =>
344+
(tokenSeparators || []).some((tokenSeparator) => ['\n', '\r\n'].includes(tokenSeparator)),
341345
[tokenSeparators],
342346
);
343347

@@ -446,7 +450,7 @@ export default function generateSelector<
446450
});
447451
if (
448452
mode === 'tags' &&
449-
filteredOptions.every(opt => opt[optionFilterProp] !== mergedSearchValue)
453+
filteredOptions.every((opt) => opt[optionFilterProp] !== mergedSearchValue)
450454
) {
451455
filteredOptions.unshift({
452456
value: mergedSearchValue,
@@ -682,7 +686,7 @@ export default function generateSelector<
682686

683687
if (mode !== 'tags') {
684688
patchRawValues = patchLabels
685-
.map(label => {
689+
.map((label) => {
686690
const item = mergedFlattenOptions.find(
687691
({ data }) => data[mergedOptionLabelProp] === label,
688692
);
@@ -695,7 +699,7 @@ export default function generateSelector<
695699
new Set<RawValueType>([...mergedRawValue, ...patchRawValues]),
696700
);
697701
triggerChange(newRawValues);
698-
newRawValues.forEach(newRawValue => {
702+
newRawValues.forEach((newRawValue) => {
699703
triggerSelect(newRawValue, true, 'input');
700704
});
701705

@@ -719,9 +723,11 @@ export default function generateSelector<
719723
// If menu is open, OptionList will take charge
720724
// If mode isn't tags, press enter is not meaningful when you can't see any option
721725
const onSearchSubmit = (searchText: string) => {
722-
const newRawValues = Array.from(new Set<RawValueType>([...mergedRawValue, searchText]));
726+
const newRawValues = Array.from(
727+
new Set<RawValueType>([...mergedRawValue, searchText]),
728+
);
723729
triggerChange(newRawValues);
724-
newRawValues.forEach(newRawValue => {
730+
newRawValues.forEach((newRawValue) => {
725731
triggerSelect(newRawValue, true, 'input');
726732
});
727733
setInnerSearchValue('');
@@ -845,10 +851,10 @@ export default function generateSelector<
845851
}
846852
};
847853

848-
const activeTimeoutIds: number[] = [];
854+
const activeTimeoutIds: any[] = [];
849855
useEffect(
850856
() => () => {
851-
activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId));
857+
activeTimeoutIds.forEach((timeoutId) => clearTimeout(timeoutId));
852858
activeTimeoutIds.splice(0, activeTimeoutIds.length);
853859
},
854860
[],

tests/Combobox.test.tsx

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
/* eslint-disable max-classes-per-file */
2+
13
import { mount } from 'enzyme';
24
import KeyCode from 'rc-util/lib/KeyCode';
35
import React from 'react';
46
import { resetWarned } from 'rc-util/lib/warning';
5-
import Select, { Option, SelectProps } from '../src';
7+
import type { SelectProps } from '../src';
8+
import Select, { Option } from '../src';
69
import focusTest from './shared/focusTest';
710
import keyDownTest from './shared/keyDownTest';
811
import openControlledTest from './shared/openControlledTest';
@@ -11,7 +14,7 @@ import allowClearTest from './shared/allowClearTest';
1114
import throwOptionValue from './shared/throwOptionValue';
1215

1316
async function delay(timeout = 0) {
14-
return new Promise(resolve => {
17+
return new Promise((resolve) => {
1518
setTimeout(resolve, timeout);
1619
});
1720
}
@@ -139,13 +142,16 @@ describe('Select.Combobox', () => {
139142
public handleChange = () => {
140143
setTimeout(() => {
141144
this.setState({
142-
data: [{ key: '1', label: '1' }, { key: '2', label: '2' }],
145+
data: [
146+
{ key: '1', label: '1' },
147+
{ key: '2', label: '2' },
148+
],
143149
});
144150
}, 500);
145151
};
146152

147153
public render() {
148-
const options = this.state.data.map(item => (
154+
const options = this.state.data.map((item) => (
149155
<Option value={item.key}>{item.label}</Option>
150156
));
151157
return (
@@ -174,19 +180,25 @@ describe('Select.Combobox', () => {
174180
jest.useFakeTimers();
175181
class AsyncCombobox extends React.Component {
176182
public state = {
177-
data: [{ key: '1', label: '1' }, { key: '2', label: '2' }],
183+
data: [
184+
{ key: '1', label: '1' },
185+
{ key: '2', label: '2' },
186+
],
178187
};
179188

180189
public onSelect = () => {
181190
setTimeout(() => {
182191
this.setState({
183-
data: [{ key: '3', label: '3' }, { key: '4', label: '4' }],
192+
data: [
193+
{ key: '3', label: '3' },
194+
{ key: '4', label: '4' },
195+
],
184196
});
185197
}, 500);
186198
};
187199

188200
public render() {
189-
const options = this.state.data.map(item => (
201+
const options = this.state.data.map((item) => (
190202
<Option value={item.key}>{item.label}</Option>
191203
));
192204
return (
@@ -243,10 +255,7 @@ describe('Select.Combobox', () => {
243255
</Select>,
244256
);
245257

246-
wrapper
247-
.find('.rc-select-item-option')
248-
.first()
249-
.simulate('mouseMove');
258+
wrapper.find('.rc-select-item-option').first().simulate('mouseMove');
250259

251260
expect(wrapper.find('input').props().value).toBeFalsy();
252261
});
@@ -341,7 +350,7 @@ describe('Select.Combobox', () => {
341350
options: [],
342351
};
343352

344-
public updateOptions = value => {
353+
public updateOptions = (value) => {
345354
const options = [value, value + value, value + value + value];
346355
this.setState({
347356
options,
@@ -351,7 +360,7 @@ describe('Select.Combobox', () => {
351360
public render() {
352361
return (
353362
<Select mode="combobox" onChange={this.updateOptions}>
354-
{this.state.options.map(opt => (
363+
{this.state.options.map((opt) => (
355364
<Option value={opt}>{opt}</Option>
356365
))}
357366
</Select>
@@ -459,4 +468,21 @@ describe('Select.Combobox', () => {
459468
expect(wrapper.find('input').props().maxLength).toBe(6);
460469
});
461470
});
471+
472+
it('typewriting should not trigger onChange multiple times', () => {
473+
const onChange = jest.fn();
474+
const wrapper = mount(<Select mode="combobox" onChange={onChange} />);
475+
476+
wrapper.find('input').simulate('compositionStart', { target: { value: '' } });
477+
wrapper.find('input').simulate('change', { target: { value: 'a' } });
478+
expect(onChange).toHaveBeenCalledTimes(1);
479+
expect(onChange).toHaveBeenLastCalledWith('a', expect.anything());
480+
481+
wrapper.find('input').simulate('change', { target: { value: '啊' } });
482+
expect(onChange).toHaveBeenCalledTimes(2);
483+
expect(onChange).toHaveBeenLastCalledWith('啊', expect.anything());
484+
485+
wrapper.find('input').simulate('compositionEnd', { target: { value: '啊' } });
486+
expect(onChange).toHaveBeenCalledTimes(2);
487+
});
462488
});

tsconfig.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "esnext",
4+
"moduleResolution": "node",
5+
"baseUrl": "./",
6+
"jsx": "preserve",
7+
"declaration": true,
8+
"skipLibCheck": true,
9+
"esModuleInterop": true,
10+
"paths": {
11+
"@/*": ["src/*"],
12+
"@@/*": ["src/.umi/*"],
13+
"rc-table": ["src/index.ts"]
14+
}
15+
}
16+
}

0 commit comments

Comments
 (0)