@hsu-react/ui 2.5.3 → 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 (57) 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/lib/components/Chart/Bubble/index.js +6 -2
  20. package/lib/components/Chart/Common/index.js +6 -2
  21. package/lib/components/Chart/Heatmap/index.js +6 -2
  22. package/lib/components/Chart/Pie/Pie3D/index.js +7 -2
  23. package/lib/components/Chart/Pie/index.js +6 -2
  24. package/lib/components/Chat/ChatInput/index.js +6 -2
  25. package/lib/components/Icon/index.js +7 -2
  26. package/lib/components/Input/Number/index.js +27 -18
  27. package/lib/components/Input/Password/index.js +40 -26
  28. package/lib/components/Input/Search/index.js +42 -26
  29. package/lib/components/Input/TextArea/index.js +54 -26
  30. package/lib/components/Input/index.js +37 -24
  31. package/lib/components/Select/_hooks/useSelectInputOptions.js +7 -2
  32. package/lib/components/Slider/index.js +23 -11
  33. package/lib/components/Upload/_hooks/useUploadFileList.js +7 -2
  34. package/lib/components/Upload/_hooks/useUploadOperations.js +7 -2
  35. package/lib/hooks/useLatestRef.d.ts +24 -0
  36. package/lib/hooks/useLatestRef.js +35 -0
  37. package/package.json +9 -3
  38. package/src/__tests__/setup.ts +26 -0
  39. package/src/components/Chart/Bubble/index.tsx +7 -2
  40. package/src/components/Chart/Common/index.tsx +7 -2
  41. package/src/components/Chart/Heatmap/index.tsx +7 -2
  42. package/src/components/Chart/Pie/Pie3D/index.tsx +7 -2
  43. package/src/components/Chart/Pie/index.tsx +7 -2
  44. package/src/components/Chat/ChatInput/index.tsx +7 -2
  45. package/src/components/Icon/index.tsx +7 -2
  46. package/src/components/Input/Number/index.tsx +25 -23
  47. package/src/components/Input/Password/index.tsx +37 -26
  48. package/src/components/Input/Search/index.tsx +39 -26
  49. package/src/components/Input/TextArea/index.test.tsx +208 -0
  50. package/src/components/Input/TextArea/index.tsx +56 -30
  51. package/src/components/Input/index.test.tsx +189 -0
  52. package/src/components/Input/index.tsx +36 -29
  53. package/src/components/Select/_hooks/useSelectInputOptions.ts +6 -9
  54. package/src/components/Slider/index.tsx +22 -12
  55. package/src/components/Upload/_hooks/useUploadFileList.ts +6 -2
  56. package/src/components/Upload/_hooks/useUploadOperations.ts +7 -2
  57. package/src/hooks/useLatestRef.ts +31 -0
@@ -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
+ });
@@ -6,6 +6,7 @@ import React, { ReactNode, useEffect, useRef, useState } from "react";
6
6
 
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 TextAreaProps
11
12
  extends Omit<
@@ -43,51 +44,71 @@ const TextArea: React.FC<TextAreaProps> = (props) => {
43
44
  style,
44
45
  ...inputConfig
45
46
  } = props;
46
- const [isComposing, setComposing] = useState<boolean>(false);
47
47
  const ref = useRef<TextAreaRef>(null);
48
48
  const [focused, setFocused] = useState<boolean>(false);
49
49
 
50
50
  // On initialization, prefer value, then fall back to defaultValue
51
51
  const initialValue =
52
52
  value !== undefined
53
- ? value?.toString() ?? ""
53
+ ? (value?.toString() ?? "")
54
54
  : defaultValue !== undefined
55
- ? defaultValue?.toString() ?? ""
56
- : "";
55
+ ? (defaultValue?.toString() ?? "")
56
+ : "";
57
57
 
58
58
  const [_value, setValue] = useState<string>(initialValue);
59
- const [lastValue, setLastValue] = useState<string>(initialValue);
60
59
  const [hasError, setHasError] = useState<boolean>(false);
61
60
  const prevValueRef = useRef<typeof value>(undefined);
62
61
 
63
- useEffect(() => {
64
- if (!isComposing && _value !== lastValue) {
65
- const trimmedValue = _value.trim();
66
- const finalValue = trimmedValue === "" ? "" : _value;
67
- setLastValue(finalValue);
68
- onChange?.(finalValue);
69
- }
70
- }, [isComposing, onChange, _value, lastValue]);
62
+ const getRefRef = useLatestRef(getRef);
63
+
64
+ // 输入法组字期间不往外通知。放 ref 不放 state:它只影响「要不要发通知」,
65
+ // 不参与渲染,而且必须在同一次事件里立刻生效
66
+ const composingRef = useRef(false);
67
+ // 最近一次通知出去的原文。只用来去掉 compositionend 与 input 两个事件的重复通知
68
+ const notifiedRef = useRef<string>(initialValue);
69
+
70
+ /**
71
+ * `onChange` 是「用户改了输入」这个**事件**的通知,不是从 state 推导出来的结果,
72
+ * 所以它在事件处理里发,不在 effect 里发。
73
+ *
74
+ * 早先是一个 effect 在发:依赖数组里放着 `onChange`,effect 体内又调它,
75
+ * 还用一个 `lastValue` state 记「这个值通知过没有」。两处都是错的——
76
+ * 1. 消费方传内联箭头(`onChange={(v) => setX(v)}`,React 里最常见的写法)时
77
+ * 每次渲染都是新引用,effect 每次渲染都重跑;
78
+ * 2. 「通知过没有」的记账放在 React state 里就**不可靠**:父级用 MobX /
79
+ * `useSyncExternalStore` 这类外部 store 回写 `value` 时,React 会以更高的
80
+ * 同步优先级重渲一次,effect 里排的那次 `setLastValue` 会被这一趟**跳过**,
81
+ * 于是 effect 读到过期的记账、判定「还没通知」,再发一次 → 父级再回写 →
82
+ * 死循环,React 抛 `Maximum update depth exceeded`,输入框被上层
83
+ * ErrorBoundary 整块摘掉。
84
+ *
85
+ * 现在 `onChange` 的引用稳不稳定完全不影响正确性:永远调到最新的那个回调,
86
+ * 且只在真的有新文本时调一次。
87
+ */
88
+ const notify = (next: string) => {
89
+ if (next === notifiedRef.current) return;
90
+ notifiedRef.current = next;
91
+ // 全是空白等同于空:外部拿到的是 "",但输入框里用户敲的空格照旧留着
92
+ onChange?.(next.trim() === "" ? "" : next);
93
+ };
71
94
 
72
95
  useEffect(() => {
73
96
  // Update internal state only when the external value prop actually changes
74
97
  if (prevValueRef.current !== value) {
75
98
  prevValueRef.current = value;
76
99
 
77
- if (value !== undefined) {
78
- setValue(value?.toString());
79
- setLastValue(value?.toString());
80
- } else {
81
- // Clear only on initialization or when explicitly set to undefined externally
82
- setValue("");
83
- setLastValue("");
84
- }
100
+ // 外部把值改了,之前通知过什么就不作数了,重新以外部值为准
101
+ const next = value !== undefined ? (value?.toString() ?? "") : "";
102
+ notifiedRef.current = next;
103
+ setValue(next);
85
104
  }
86
105
  }, [value]);
87
106
 
107
+ // 只在挂载时把 ref 交出去。`getRef` 进依赖数组同样会被内联箭头带着每次渲染重跑,
108
+ // 消费方在里面 setState 就是一个死循环
88
109
  useEffect(() => {
89
- getRef?.(ref.current);
90
- }, [getRef]);
110
+ getRefRef.current?.(ref.current);
111
+ }, [getRefRef]);
91
112
 
92
113
  useEffect(() => {
93
114
  // Use a MutationObserver to watch for error state changes
@@ -95,7 +116,7 @@ const TextArea: React.FC<TextAreaProps> = (props) => {
95
116
  if (ref.current?.resizableTextArea?.textArea) {
96
117
  const textAreaElement = ref.current.resizableTextArea.textArea;
97
118
  const hasErrorClass = textAreaElement.classList.contains(
98
- "ant-input-status-error"
119
+ "ant-input-status-error",
99
120
  );
100
121
  setHasError(hasErrorClass);
101
122
  }
@@ -135,16 +156,21 @@ const TextArea: React.FC<TextAreaProps> = (props) => {
135
156
  {prefix}
136
157
  <AntdTextArea
137
158
  ref={ref}
138
- onCompositionStart={() => setComposing(true)}
139
- onCompositionEnd={() => {
140
- setTimeout(() => {
141
- setComposing(false);
142
- }, 1);
159
+ onCompositionStart={() => {
160
+ composingRef.current = true;
161
+ }}
162
+ onCompositionEnd={(e) => {
163
+ composingRef.current = false;
164
+ // 组完字的那一下自己发通知。浏览器之间 compositionend 与 input 的先后
165
+ // 不一致,`notify` 里按原文去重,两种顺序都只会发出一次
166
+ notify(e.currentTarget.value);
143
167
  }}
144
168
  value={_value}
145
169
  autoSize={disabled && text ? autoSize || { minRows: 1 } : autoSize}
146
170
  onChange={(e) => {
147
- setValue(e.target.value);
171
+ const next = e.target.value;
172
+ setValue(next);
173
+ if (!composingRef.current) notify(next);
148
174
  }}
149
175
  onFocus={(e) => {
150
176
  setFocused(true);