@hsu-react/ui 2.5.2 → 2.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/es/components/Chart/Bubble/index.js +7 -2
  2. package/es/components/Chart/Common/index.js +7 -2
  3. package/es/components/Chart/Heatmap/index.js +7 -2
  4. package/es/components/Chart/Pie/Pie3D/index.js +8 -2
  5. package/es/components/Chart/Pie/index.js +7 -2
  6. package/es/components/Chat/ChatInput/index.js +7 -2
  7. package/es/components/Icon/index.js +8 -2
  8. package/es/components/Input/Number/index.js +28 -21
  9. package/es/components/Input/Password/index.js +44 -36
  10. package/es/components/Input/Search/index.js +44 -36
  11. package/es/components/Input/TextArea/index.js +64 -42
  12. package/es/components/Input/index.js +40 -34
  13. package/es/components/Select/_hooks/useSelectInputOptions.js +8 -2
  14. package/es/components/Slider/index.js +24 -15
  15. package/es/components/Upload/_hooks/useUploadFileList.js +8 -2
  16. package/es/components/Upload/_hooks/useUploadOperations.js +8 -2
  17. package/es/hooks/useLatestRef.d.ts +24 -0
  18. package/es/hooks/useLatestRef.js +29 -0
  19. package/es/layout/Menu/index.js +24 -6
  20. package/es/layout/Menu/index.module.scss +53 -10
  21. package/lib/components/Chart/Bubble/index.js +6 -2
  22. package/lib/components/Chart/Common/index.js +6 -2
  23. package/lib/components/Chart/Heatmap/index.js +6 -2
  24. package/lib/components/Chart/Pie/Pie3D/index.js +7 -2
  25. package/lib/components/Chart/Pie/index.js +6 -2
  26. package/lib/components/Chat/ChatInput/index.js +6 -2
  27. package/lib/components/Icon/index.js +7 -2
  28. package/lib/components/Input/Number/index.js +27 -18
  29. package/lib/components/Input/Password/index.js +40 -26
  30. package/lib/components/Input/Search/index.js +42 -26
  31. package/lib/components/Input/TextArea/index.js +54 -26
  32. package/lib/components/Input/index.js +37 -24
  33. package/lib/components/Select/_hooks/useSelectInputOptions.js +7 -2
  34. package/lib/components/Slider/index.js +23 -11
  35. package/lib/components/Upload/_hooks/useUploadFileList.js +7 -2
  36. package/lib/components/Upload/_hooks/useUploadOperations.js +7 -2
  37. package/lib/hooks/useLatestRef.d.ts +24 -0
  38. package/lib/hooks/useLatestRef.js +35 -0
  39. package/lib/layout/Menu/index.js +24 -6
  40. package/lib/layout/Menu/index.module.scss +53 -10
  41. package/package.json +9 -3
  42. package/src/__tests__/setup.ts +26 -0
  43. package/src/components/Chart/Bubble/index.tsx +7 -2
  44. package/src/components/Chart/Common/index.tsx +7 -2
  45. package/src/components/Chart/Heatmap/index.tsx +7 -2
  46. package/src/components/Chart/Pie/Pie3D/index.tsx +7 -2
  47. package/src/components/Chart/Pie/index.tsx +7 -2
  48. package/src/components/Chat/ChatInput/index.tsx +7 -2
  49. package/src/components/Icon/index.tsx +7 -2
  50. package/src/components/Input/Number/index.tsx +25 -23
  51. package/src/components/Input/Password/index.tsx +37 -26
  52. package/src/components/Input/Search/index.tsx +39 -26
  53. package/src/components/Input/TextArea/index.test.tsx +208 -0
  54. package/src/components/Input/TextArea/index.tsx +56 -30
  55. package/src/components/Input/index.test.tsx +189 -0
  56. package/src/components/Input/index.tsx +36 -29
  57. package/src/components/Select/_hooks/useSelectInputOptions.ts +6 -9
  58. package/src/components/Slider/index.tsx +22 -12
  59. package/src/components/Upload/_hooks/useUploadFileList.ts +6 -2
  60. package/src/components/Upload/_hooks/useUploadOperations.ts +7 -2
  61. package/src/hooks/useLatestRef.ts +31 -0
  62. package/src/layout/Menu/index.md +11 -0
  63. package/src/layout/Menu/index.module.scss +53 -10
  64. package/src/layout/Menu/index.tsx +24 -7
@@ -7,6 +7,7 @@ import { ChartCommonProps, ChartOptionType, ChartsOption } from "../..";
7
7
  import { useMouseOverHandler, useGlobalOutHandler } from "./_hooks";
8
8
  import { getPie3DOption } from "./_utils/option";
9
9
  import styles from "../../index.module.scss";
10
+ import { useLatestRef } from "../../../../hooks/useLatestRef";
10
11
 
11
12
  export interface Pie3DDataItem {
12
13
  name: string;
@@ -170,6 +171,10 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
170
171
  { minHeight, maxHeight, yOffset, autoRotate }
171
172
  );
172
173
 
174
+ const onChartRef = useLatestRef(onChart);
175
+
176
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
177
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
173
178
  useEffect(() => {
174
179
  if (!chartRef.current || !containerReady) return;
175
180
 
@@ -177,7 +182,7 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
177
182
  if (!chart) {
178
183
  chart = echarts.init(chartRef.current);
179
184
  chartInstanceRef.current = chart;
180
- onChart?.(chart);
185
+ onChartRef.current?.(chart);
181
186
  }
182
187
 
183
188
  chart.setOption(chartOption as ChartOptionType, true);
@@ -211,7 +216,7 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
211
216
  handleResize,
212
217
  handleMouseOver,
213
218
  handleGlobalOut,
214
- onChart,
219
+ onChartRef,
215
220
  onClick,
216
221
  containerReady,
217
222
  ]);
@@ -13,6 +13,7 @@ import { autoScrollLegend } from "../chartUtils";
13
13
 
14
14
  import styles from "../index.module.scss";
15
15
  import ChartPie3D, { ChartPie3DProps } from "./Pie3D";
16
+ import { useLatestRef } from "../../../hooks/useLatestRef";
16
17
 
17
18
  export interface ChartPieProps extends ChartCommonProps {
18
19
  chartTitle?: string;
@@ -252,6 +253,10 @@ const ChartPie: ChartPieFC = (props) => {
252
253
  chartInstanceRef.current?.resize();
253
254
  }, []);
254
255
 
256
+ const onChartRef = useLatestRef(onChart);
257
+
258
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
259
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
255
260
  // Initialize the chart
256
261
  useEffect(() => {
257
262
  if (!chartRef.current || !containerReady) return;
@@ -262,7 +267,7 @@ const ChartPie: ChartPieFC = (props) => {
262
267
  chart = echarts.init(chartRef.current);
263
268
  chartInstanceRef.current = chart;
264
269
  // Call the onChart callback on first initialization
265
- onChart?.(chart);
270
+ onChartRef.current?.(chart);
266
271
  }
267
272
 
268
273
  // Apply the option
@@ -315,7 +320,7 @@ const ChartPie: ChartPieFC = (props) => {
315
320
  }, [
316
321
  chartOption,
317
322
  handleResize,
318
- onChart,
323
+ onChartRef,
319
324
  onClick,
320
325
  enableLegendAutoScroll,
321
326
  seriesData,
@@ -14,6 +14,7 @@ import {
14
14
  } from "./_components";
15
15
  import classNames from "classnames";
16
16
  import Button, { ButtonProps } from "../../Button";
17
+ import { useLatestRef } from "../../../hooks/useLatestRef";
17
18
  export type { AgentToggleConfig } from "./_components";
18
19
 
19
20
  export interface AgentConfig {
@@ -127,6 +128,10 @@ const ChatInput: React.FC<ChatInputProps> = (props) => {
127
128
  // Use external state or internal state
128
129
  const agentsState = isControlled ? externalAgentsState : internalAgentsState;
129
130
 
131
+ const onAgentsChangeRef = useLatestRef(onAgentsChange);
132
+
133
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
134
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
130
135
  // When the model switches, sync the agents state (only in uncontrolled mode)
131
136
  useEffect(() => {
132
137
  if (isControlled) return; // In controlled mode, state is managed externally
@@ -136,12 +141,12 @@ const ChatInput: React.FC<ChatInputProps> = (props) => {
136
141
  );
137
142
  if (currentModel?.agents && currentModel.agents.length > 0) {
138
143
  setInternalAgentsState(currentModel.agents);
139
- onAgentsChange?.(currentModel.agents);
144
+ onAgentsChangeRef.current?.(currentModel.agents);
140
145
  }
141
146
  }, [
142
147
  modelConfig?.modelType,
143
148
  modelConfig?.modelList,
144
- onAgentsChange,
149
+ onAgentsChangeRef,
145
150
  isControlled,
146
151
  ]);
147
152
 
@@ -8,6 +8,7 @@ import * as AntdIcons from "@ant-design/icons";
8
8
  import React, { useCallback, useEffect, useRef } from "react";
9
9
  import classNames from "classnames";
10
10
  import styles from "./index.module.scss";
11
+ import { useLatestRef } from "../../hooks/useLatestRef";
11
12
 
12
13
  type AntdNamedIconComponent = React.ForwardRefExoticComponent<
13
14
  {
@@ -73,9 +74,13 @@ const Icon = React.forwardRef<HTMLSpanElement, IconProps>((props, forwardedRef)
73
74
  [forwardedRef]
74
75
  );
75
76
 
77
+ const onRefRef = useLatestRef(onRef);
78
+
79
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
80
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
76
81
  useEffect(() => {
77
- onRef?.(ref);
78
- }, [onRef, ref]);
82
+ onRefRef.current?.(ref);
83
+ }, [onRefRef, ref]);
79
84
 
80
85
  const mergedStyle: React.CSSProperties = {
81
86
  color,
@@ -8,8 +8,8 @@ import { CloseCircleFilled } from "@ant-design/icons";
8
8
 
9
9
  import classNames from "classnames";
10
10
  import styles from "./index.module.scss";
11
- import { useDebounceEffect } from "ahooks";
12
11
  import type { InputNumberRef } from "../../../types/antd";
12
+ import { useLatestRef } from "../../../hooks/useLatestRef";
13
13
 
14
14
  export interface InputNumberProps extends Omit<
15
15
  AntdInputNumberProps,
@@ -44,6 +44,7 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
44
44
  // `getRef` keeps handing consumers an `HTMLInputElement` exactly as before.
45
45
  const ref = useRef<InputNumberRef>(null);
46
46
  const wrapperRef = useRef<HTMLDivElement>(null);
47
+ const getRefRef = useLatestRef(getRef);
47
48
 
48
49
  // On initialization, prefer value, then fall back to defaultValue
49
50
  const initialValue =
@@ -58,22 +59,21 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
58
59
  : "";
59
60
 
60
61
  const [_value, setValue] = useState<string>(initialValue);
61
- const [lastValue, setLastValue] = useState<string>(initialValue);
62
62
  const prevValueRef = useRef<typeof value>(undefined);
63
+ // 最近一次通知出去的文本,代替原来那个用 state 记账的写法
64
+ const notifiedRef = useRef<string>(initialValue);
63
65
 
64
- useDebounceEffect(
65
- () => {
66
- if (_value !== lastValue) {
67
- setLastValue(_value);
68
-
69
- onChange?.(_value);
70
- }
71
- },
72
- [_value, lastValue, onChange],
73
- {
74
- wait: 10,
75
- },
76
- );
66
+ /**
67
+ * `onChange` 是「用户改了输入」这个**事件**的通知,不是从 state 推导出来的结果,
68
+ * 所以在事件处理里发,不在 effect 里发。详见 `TextArea/index.tsx` 里那段说明——
69
+ * 旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
70
+ * 会让传内联箭头的消费方死循环。
71
+ */
72
+ const notify = (next: string) => {
73
+ if (next === notifiedRef.current) return;
74
+ notifiedRef.current = next;
75
+ onChange?.(next);
76
+ };
77
77
 
78
78
  useEffect(() => {
79
79
  // Update internal state only when the external value prop actually changes
@@ -96,17 +96,18 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
96
96
  Number(value) === Number(_value);
97
97
 
98
98
  if (sameNumber) {
99
- // 只同步「已提交值」,不动正在编辑的文本
100
- setLastValue(_value);
99
+ // 只同步「已通知过的值」,不动正在编辑的文本
100
+ notifiedRef.current = _value;
101
101
  } else if (value !== undefined && value !== null) {
102
102
  const newValue =
103
103
  typeof value === "number" ? `${value}` : value?.toString();
104
+ // 外部把值改了,之前通知过什么就不作数了,重新以外部值为准
105
+ notifiedRef.current = newValue;
104
106
  setValue(newValue);
105
- setLastValue(newValue);
106
107
  } else {
107
108
  // Clear only on initialization or when explicitly set to undefined/null externally
109
+ notifiedRef.current = "";
108
110
  setValue("");
109
- setLastValue("");
110
111
  }
111
112
  }
112
113
  // `_value` 是刻意不进依赖的:这个 effect 只该在**外部** value 变化时跑,
@@ -114,15 +115,15 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
114
115
  // eslint-disable-next-line react-hooks/exhaustive-deps
115
116
  }, [value]);
116
117
 
118
+ // 只在挂载时把 ref 交出去。`getRef` 进依赖数组同样会被内联箭头带着每次渲染重跑
117
119
  useEffect(() => {
118
- getRef?.(ref.current);
119
- }, [getRef]);
120
+ getRefRef.current?.(ref.current);
121
+ }, [getRefRef]);
120
122
 
121
123
  const handleClear = (e: React.MouseEvent) => {
122
124
  e.stopPropagation();
123
125
  setValue("");
124
- setLastValue("");
125
- onChange?.("");
126
+ notify("");
126
127
  };
127
128
 
128
129
  const showClear =
@@ -138,6 +139,7 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
138
139
  ? ""
139
140
  : (typeof e === "number" ? e : e || "").toString();
140
141
  setValue(newValue);
142
+ notify(newValue);
141
143
  }}
142
144
  className={classNames(styles.antdInput, className)}
143
145
  controls={false}
@@ -6,6 +6,7 @@ import React, { useEffect, useRef, useState } from "react";
6
6
  import { InputRef } from "antd/es/input";
7
7
  import classNames from "classnames";
8
8
  import styles from "./index.module.scss";
9
+ import { useLatestRef } from "../../../hooks/useLatestRef";
9
10
 
10
11
  export interface PasswordProps
11
12
  extends Omit<
@@ -19,8 +20,11 @@ export interface PasswordProps
19
20
  const Password: React.FC<PasswordProps> = (props) => {
20
21
  const { onChange, getRef, value, defaultValue, className, ...inputConfig } =
21
22
  props;
22
- const [isComposing, setComposing] = useState<boolean>(false);
23
23
  const ref = useRef<InputRef>(null);
24
+ const getRefRef = useLatestRef(getRef);
25
+ // 输入法组字期间不往外通知。放 ref 不放 state:它只决定「要不要发通知」,
26
+ // 不参与渲染,而且必须在同一次事件里立刻生效
27
+ const composingRef = useRef(false);
24
28
 
25
29
  // On initialization, prefer value, then fall back to defaultValue
26
30
  const initialValue =
@@ -31,51 +35,58 @@ const Password: React.FC<PasswordProps> = (props) => {
31
35
  : "";
32
36
 
33
37
  const [_value, setValue] = useState<string>(initialValue);
34
- const [lastValue, setLastValue] = useState<string>(initialValue);
35
38
  const prevValueRef = useRef<typeof value>(undefined);
39
+ // 最近一次通知出去的原文。只用来去掉 compositionend 与 input 两个事件的重复通知
40
+ const notifiedRef = useRef<string>(initialValue);
36
41
 
37
- useEffect(() => {
38
- if (!isComposing && _value !== lastValue) {
39
- const trimmedValue = _value.trim();
40
- const finalValue = trimmedValue === "" ? "" : _value;
41
- setLastValue(finalValue);
42
- onChange?.(finalValue);
43
- }
44
- }, [_value, isComposing, lastValue, onChange]);
42
+ /**
43
+ * `onChange` 是「用户改了输入」这个**事件**的通知,不是从 state 推导出来的结果,
44
+ * 所以在事件处理里发,不在 effect 里发。详见 `TextArea/index.tsx` 里那段说明——
45
+ * 旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
46
+ * 会让传内联箭头的消费方死循环。
47
+ */
48
+ const notify = (next: string) => {
49
+ if (next === notifiedRef.current) return;
50
+ notifiedRef.current = next;
51
+ // 全是空白等同于空
52
+ onChange?.(next.trim() === "" ? "" : next);
53
+ };
45
54
 
46
55
  useEffect(() => {
47
56
  // Update internal state only when the external value prop actually changes
48
57
  if (prevValueRef.current !== value) {
49
58
  prevValueRef.current = value;
50
59
 
51
- if (value !== undefined) {
52
- setValue(value?.toString());
53
- setLastValue(value?.toString());
54
- } else {
55
- // Clear only on initialization or when explicitly set to undefined externally
56
- setValue("");
57
- setLastValue("");
58
- }
60
+ // 外部把值改了,之前通知过什么就不作数了,重新以外部值为准
61
+ const next = value !== undefined ? (value?.toString() ?? "") : "";
62
+ notifiedRef.current = next;
63
+ setValue(next);
59
64
  }
60
65
  }, [value]);
61
66
 
67
+ // 只在挂载时把 ref 交出去。`getRef` 进依赖数组同样会被内联箭头带着每次渲染重跑
62
68
  useEffect(() => {
63
- getRef?.(ref.current);
64
- }, [getRef]);
69
+ getRefRef.current?.(ref.current);
70
+ }, [getRefRef]);
65
71
 
66
72
  return (
67
73
  <AntdPassword
68
74
  className={classNames(styles.antdInput, className)}
69
75
  ref={ref}
70
- onCompositionStart={() => setComposing(true)}
71
- onCompositionEnd={() => {
72
- setTimeout(() => {
73
- setComposing(false);
74
- }, 1);
76
+ onCompositionStart={() => {
77
+ composingRef.current = true;
78
+ }}
79
+ onCompositionEnd={(e) => {
80
+ composingRef.current = false;
81
+ // 组完字的那一下自己发通知。浏览器之间 compositionend 与 input 的先后
82
+ // 不一致,`notify` 里按原文去重,两种顺序都只会发出一次
83
+ notify(e.currentTarget.value);
75
84
  }}
76
85
  value={_value}
77
86
  onChange={(e) => {
78
- setValue(e.target.value);
87
+ const next = e.target.value;
88
+ setValue(next);
89
+ if (!composingRef.current) notify(next);
79
90
  }}
80
91
  {...inputConfig}
81
92
  />
@@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
4
4
  import AntdSearch from "antd/es/input/Search";
5
5
  import classNames from "classnames";
6
6
  import styles from "./index.module.scss";
7
+ import { useLatestRef } from "../../../hooks/useLatestRef";
7
8
 
8
9
  export interface SearchProps
9
10
  extends Omit<
@@ -17,8 +18,11 @@ export interface SearchProps
17
18
  const Search: React.FC<SearchProps> = (props) => {
18
19
  const { onChange, getRef, value, defaultValue, className, ...inputConfig } =
19
20
  props;
20
- const [isComposing, setComposing] = useState<boolean>(false);
21
21
  const ref = useRef<InputRef>(null);
22
+ const getRefRef = useLatestRef(getRef);
23
+ // 输入法组字期间不往外通知。放 ref 不放 state:它只决定「要不要发通知」,
24
+ // 不参与渲染,而且必须在同一次事件里立刻生效
25
+ const composingRef = useRef(false);
22
26
 
23
27
  // On initialization, prefer value, then fall back to defaultValue
24
28
  const initialValue =
@@ -29,50 +33,59 @@ const Search: React.FC<SearchProps> = (props) => {
29
33
  : "";
30
34
 
31
35
  const [_value, setValue] = useState<string>(initialValue);
32
- const [lastValue, setLastValue] = useState<string>(initialValue);
33
36
  const prevValueRef = useRef<typeof value>(undefined);
37
+ // 最近一次通知出去的原文。只用来去掉 compositionend 与 input 两个事件的重复通知
38
+ const notifiedRef = useRef<string>(initialValue);
34
39
 
35
- useEffect(() => {
36
- if (!isComposing && _value !== lastValue) {
37
- const trimmedValue = _value.trim();
38
- const finalValue = trimmedValue === "" ? "" : _value;
39
- setLastValue(finalValue);
40
- onChange?.(finalValue);
41
- }
42
- }, [_value, isComposing, lastValue, onChange]);
40
+ /**
41
+ * `onChange` 是「用户改了输入」这个**事件**的通知,不是从 state 推导出来的结果,
42
+ * 所以在事件处理里发,不在 effect 里发。详见 `TextArea/index.tsx` 里那段说明——
43
+ * 旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
44
+ * 会让传内联箭头的消费方死循环。
45
+ */
46
+ const notify = (next: string) => {
47
+ if (next === notifiedRef.current) return;
48
+ notifiedRef.current = next;
49
+ // 全是空白等同于空
50
+ onChange?.(next.trim() === "" ? "" : next);
51
+ };
43
52
 
44
53
  useEffect(() => {
45
54
  // Update internal state only when the external value prop actually changes
46
55
  if (prevValueRef.current !== value) {
47
56
  prevValueRef.current = value;
48
57
 
49
- if (value !== undefined) {
50
- setValue(value?.toString());
51
- setLastValue(value?.toString());
52
- } else {
53
- // Clear only on initialization or when explicitly set to undefined externally
54
- setValue("");
55
- setLastValue("");
56
- }
58
+ // 外部把值改了,之前通知过什么就不作数了,重新以外部值为准
59
+ const next = value !== undefined ? (value?.toString() ?? "") : "";
60
+ notifiedRef.current = next;
61
+ setValue(next);
57
62
  }
58
63
  }, [value]);
59
64
 
65
+ // 只在挂载时把 ref 交出去。`getRef` 进依赖数组同样会被内联箭头带着每次渲染重跑
60
66
  useEffect(() => {
61
- getRef?.(ref.current);
62
- }, [getRef]);
67
+ getRefRef.current?.(ref.current);
68
+ }, [getRefRef]);
63
69
 
64
70
  return (
65
71
  <AntdSearch
66
72
  ref={ref}
67
73
  className={classNames(styles.antdInput, className)}
68
- onCompositionStart={() => setComposing(true)}
69
- onCompositionEnd={() => {
70
- setTimeout(() => {
71
- setComposing(false);
72
- }, 1);
74
+ onCompositionStart={() => {
75
+ composingRef.current = true;
76
+ }}
77
+ onCompositionEnd={(e) => {
78
+ composingRef.current = false;
79
+ // 组完字的那一下自己发通知。浏览器之间 compositionend 与 input 的先后
80
+ // 不一致,`notify` 里按原文去重,两种顺序都只会发出一次
81
+ notify(e.currentTarget.value);
73
82
  }}
74
83
  value={_value}
75
- onChange={(e) => setValue(e.target.value)}
84
+ onChange={(e) => {
85
+ const next = e.target.value;
86
+ setValue(next);
87
+ if (!composingRef.current) notify(next);
88
+ }}
76
89
  {...inputConfig}
77
90
  />
78
91
  );
@@ -0,0 +1,208 @@
1
+ import React, { useState } from "react";
2
+ import { makeAutoObservable } from "mobx";
3
+ import { observer } from "mobx-react-lite";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
6
+
7
+ import TextArea from ".";
8
+
9
+ /** 受控消费方:`value` 从自己的 state 来,`onChange` 写回同一份 state */
10
+ function Controlled({
11
+ onChange,
12
+ initial = "",
13
+ ...rest
14
+ }: {
15
+ onChange: (v: string) => void;
16
+ initial?: string;
17
+ } & Record<string, unknown>) {
18
+ const [v, setV] = useState(initial);
19
+
20
+ return (
21
+ <TextArea
22
+ value={v}
23
+ // 内联箭头:每次渲染都是新引用。消费方最常见的写法,组件必须扛得住
24
+ onChange={(next) => {
25
+ setV(next);
26
+ onChange(next);
27
+ }}
28
+ {...rest}
29
+ />
30
+ );
31
+ }
32
+
33
+ const type = (el: HTMLElement, value: string) =>
34
+ fireEvent.change(el, { target: { value } });
35
+
36
+ describe("TextArea", () => {
37
+ it("消费方传内联箭头 onChange 时不进入无限循环", async () => {
38
+ const spy = vi.fn();
39
+ render(<Controlled onChange={spy} />);
40
+ const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
41
+
42
+ type(ta, "a");
43
+
44
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("a"));
45
+ // 敲一个字只该回调一次;进了循环这里会是几十上百次
46
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
47
+ expect(ta.value).toBe("a");
48
+
49
+ spy.mockClear();
50
+ type(ta, "ab");
51
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("ab"));
52
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
53
+ expect(ta.value).toBe("ab");
54
+ });
55
+
56
+ it("全是空白的输入回调成空串,且不反复回调", async () => {
57
+ const spy = vi.fn();
58
+ render(<Controlled onChange={spy} />);
59
+ const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
60
+
61
+ type(ta, " ");
62
+
63
+ await waitFor(() => expect(spy).toHaveBeenCalledWith(""));
64
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
65
+ });
66
+
67
+ it("非受控:不传 value 时内部自己维护,onChange 照常只回调一次", async () => {
68
+ const spy = vi.fn();
69
+ render(<TextArea defaultValue="x" onChange={spy} />);
70
+ const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
71
+ expect(ta.value).toBe("x");
72
+
73
+ type(ta, "xy");
74
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("xy"));
75
+ expect(spy.mock.calls.length).toBe(1);
76
+ expect(ta.value).toBe("xy");
77
+ });
78
+
79
+ it("外部改 value 会同步进输入框,且不额外触发 onChange", async () => {
80
+ const spy = vi.fn();
81
+ const { rerender } = render(<TextArea value="a" onChange={spy} />);
82
+ const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
83
+ expect(ta.value).toBe("a");
84
+
85
+ rerender(<TextArea value="b" onChange={spy} />);
86
+ await waitFor(() => expect(ta.value).toBe("b"));
87
+ expect(spy).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("maxLength 生效,en 时不截断", () => {
91
+ const { unmount } = render(<TextArea maxLength={3} />);
92
+ expect(
93
+ (screen.getByRole("textbox") as HTMLTextAreaElement).maxLength,
94
+ ).toBe(3);
95
+ unmount();
96
+
97
+ render(<TextArea maxLength={3} en />);
98
+ expect(
99
+ (screen.getByRole("textbox") as HTMLTextAreaElement).maxLength,
100
+ ).toBe(-1);
101
+ });
102
+
103
+ it("disabled 透传", () => {
104
+ render(<TextArea disabled />);
105
+ expect((screen.getByRole("textbox") as HTMLTextAreaElement).disabled).toBe(
106
+ true,
107
+ );
108
+ });
109
+
110
+ it("中文输入法组字期间不回调,组完才回调一次", async () => {
111
+ const spy = vi.fn();
112
+ render(<Controlled onChange={spy} />);
113
+ const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
114
+
115
+ fireEvent.compositionStart(ta);
116
+ type(ta, "n");
117
+ type(ta, "ni");
118
+ await new Promise((r) => setTimeout(r, 20));
119
+ expect(spy).not.toHaveBeenCalled();
120
+
121
+ fireEvent.compositionEnd(ta);
122
+ type(ta, "你");
123
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("你"));
124
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
125
+ });
126
+ /**
127
+ * 这一条是真正的回归用例:父级用 MobX(`useSyncExternalStore`)回写 value,
128
+ * React 会以同步优先级重渲,组件内排在 effect 里的 state 更新会被那一趟跳过。
129
+ * 旧实现用 state 记「通知过没有」,读到过期记账就会再发一次通知 → 死循环。
130
+ */
131
+ it("父级是 MobX observer、又传内联箭头时也不循环", async () => {
132
+ class Store {
133
+ private _draft: { body: string } | undefined = { body: "hello" };
134
+ constructor() {
135
+ makeAutoObservable(this);
136
+ }
137
+ get draft() {
138
+ return this._draft;
139
+ }
140
+ setBody = (body: string) => {
141
+ if (this._draft) this._draft = { ...this._draft, body };
142
+ };
143
+ }
144
+
145
+ const store = new Store();
146
+ const spy = vi.fn();
147
+ const Cmp = observer(() => {
148
+ const { draft, setBody } = store;
149
+ if (!draft) return null;
150
+
151
+ return (
152
+ <TextArea
153
+ value={draft.body}
154
+ onChange={(v) => {
155
+ spy(v);
156
+ setBody(v);
157
+ }}
158
+ />
159
+ );
160
+ });
161
+
162
+ render(<Cmp />);
163
+ const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
164
+ type(ta, "helloX");
165
+
166
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("helloX"));
167
+ await new Promise((r) => setTimeout(r, 100));
168
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
169
+ expect(store.draft?.body).toBe("helloX");
170
+ });
171
+
172
+ it("内联 getRef 也不会把消费方转崩", async () => {
173
+ const seen: unknown[] = [];
174
+ const Cmp = () => {
175
+ const [, setTick] = useState(0);
176
+
177
+ return (
178
+ <TextArea
179
+ getRef={(r) => {
180
+ seen.push(r);
181
+ setTick((n) => n + 1);
182
+ }}
183
+ />
184
+ );
185
+ };
186
+
187
+ render(<Cmp />);
188
+ await new Promise((r) => setTimeout(r, 100));
189
+ // 只在挂载时交一次 ref;旧写法会跟着每次渲染重跑,这里会是几十上百次
190
+ expect(seen.length).toBe(1);
191
+ expect(seen[0]).not.toBeNull();
192
+ });
193
+
194
+ it("外部把 value 置为 undefined 会清空", async () => {
195
+ const { rerender } = render(<TextArea value="a" />);
196
+ const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
197
+ expect(ta.value).toBe("a");
198
+
199
+ rerender(<TextArea value={undefined} />);
200
+ await waitFor(() => expect(ta.value).toBe(""));
201
+ });
202
+
203
+ it("autoSize / textAreaClassName 等透传照旧", () => {
204
+ render(<TextArea textAreaClassName="my-area" autoSize={{ minRows: 3 }} />);
205
+ const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
206
+ expect(ta.className).toContain("my-area");
207
+ });
208
+ });