@hsu-react/ui 2.5.3 → 2.5.5

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 (90) hide show
  1. package/es/components/ChainGraph/_hooks/useChainGraphData.js +29 -8
  2. package/es/components/ChainGraph/_hooks/useChainGraphLayout.js +14 -3
  3. package/es/components/Chart/Bar/index.js +49 -19
  4. package/es/components/Chart/Bubble/index.js +21 -6
  5. package/es/components/Chart/Common/index.js +7 -2
  6. package/es/components/Chart/Heatmap/index.js +21 -6
  7. package/es/components/Chart/Line/index.js +49 -19
  8. package/es/components/Chart/Pie/Pie3D/index.js +22 -6
  9. package/es/components/Chart/Pie/index.js +21 -6
  10. package/es/components/Chart/Sankey/index.js +11 -2
  11. package/es/components/Chart/Tree/index.js +11 -2
  12. package/es/components/Chat/ChatInput/index.js +7 -2
  13. package/es/components/Icon/index.js +8 -2
  14. package/es/components/Input/Number/index.js +28 -21
  15. package/es/components/Input/Password/index.js +44 -36
  16. package/es/components/Input/Search/index.js +44 -36
  17. package/es/components/Input/TextArea/index.js +64 -42
  18. package/es/components/Input/index.js +40 -34
  19. package/es/components/Select/_hooks/useSelectComposition.js +6 -2
  20. package/es/components/Select/_hooks/useSelectInputOptions.js +8 -2
  21. package/es/components/Slider/index.js +24 -15
  22. package/es/components/Table/_hooks/useAutoScrolling.js +16 -7
  23. package/es/components/Upload/_hooks/useUploadFileList.js +8 -2
  24. package/es/components/Upload/_hooks/useUploadOperations.js +8 -2
  25. package/es/hooks/useLatestRef.d.ts +24 -0
  26. package/es/hooks/useLatestRef.js +29 -0
  27. package/es/layout/Menu/_hooks/useOnlyLvOneMenu.js +9 -2
  28. package/lib/components/ChainGraph/_hooks/useChainGraphData.js +24 -7
  29. package/lib/components/ChainGraph/_hooks/useChainGraphLayout.js +12 -2
  30. package/lib/components/Chart/Bar/index.js +45 -18
  31. package/lib/components/Chart/Bubble/index.js +19 -6
  32. package/lib/components/Chart/Common/index.js +6 -2
  33. package/lib/components/Chart/Heatmap/index.js +19 -6
  34. package/lib/components/Chart/Line/index.js +45 -18
  35. package/lib/components/Chart/Pie/Pie3D/index.js +20 -6
  36. package/lib/components/Chart/Pie/index.js +19 -6
  37. package/lib/components/Chart/Sankey/index.js +10 -2
  38. package/lib/components/Chart/Tree/index.js +10 -2
  39. package/lib/components/Chat/ChatInput/index.js +6 -2
  40. package/lib/components/Icon/index.js +7 -2
  41. package/lib/components/Input/Number/index.js +27 -18
  42. package/lib/components/Input/Password/index.js +40 -26
  43. package/lib/components/Input/Search/index.js +42 -26
  44. package/lib/components/Input/TextArea/index.js +54 -26
  45. package/lib/components/Input/index.js +37 -24
  46. package/lib/components/Select/_hooks/useSelectComposition.js +6 -2
  47. package/lib/components/Select/_hooks/useSelectInputOptions.js +7 -2
  48. package/lib/components/Slider/index.js +23 -11
  49. package/lib/components/Table/_hooks/useAutoScrolling.js +16 -7
  50. package/lib/components/Upload/_hooks/useUploadFileList.js +7 -2
  51. package/lib/components/Upload/_hooks/useUploadOperations.js +7 -2
  52. package/lib/hooks/useLatestRef.d.ts +24 -0
  53. package/lib/hooks/useLatestRef.js +35 -0
  54. package/lib/layout/Menu/_hooks/useOnlyLvOneMenu.js +8 -2
  55. package/package.json +9 -3
  56. package/src/__tests__/callbackDepsGuard.test.ts +119 -0
  57. package/src/__tests__/setup.ts +26 -0
  58. package/src/components/ChainGraph/_hooks/callbacks.test.tsx +155 -0
  59. package/src/components/ChainGraph/_hooks/useChainGraphData.ts +38 -10
  60. package/src/components/ChainGraph/_hooks/useChainGraphLayout.ts +16 -3
  61. package/src/components/Chart/Bar/index.tsx +59 -21
  62. package/src/components/Chart/Bubble/index.tsx +22 -7
  63. package/src/components/Chart/Common/index.tsx +7 -2
  64. package/src/components/Chart/Heatmap/index.tsx +25 -6
  65. package/src/components/Chart/Line/index.tsx +59 -21
  66. package/src/components/Chart/Pie/Pie3D/index.tsx +22 -7
  67. package/src/components/Chart/Pie/index.tsx +22 -7
  68. package/src/components/Chart/Sankey/index.tsx +10 -4
  69. package/src/components/Chart/Tree/index.tsx +10 -4
  70. package/src/components/Chart/callbacks.test.tsx +315 -0
  71. package/src/components/Chat/ChatInput/index.tsx +7 -2
  72. package/src/components/Icon/index.tsx +7 -2
  73. package/src/components/Input/Number/index.tsx +25 -23
  74. package/src/components/Input/Password/index.tsx +37 -26
  75. package/src/components/Input/Search/index.tsx +39 -26
  76. package/src/components/Input/TextArea/index.test.tsx +208 -0
  77. package/src/components/Input/TextArea/index.tsx +56 -30
  78. package/src/components/Input/index.test.tsx +189 -0
  79. package/src/components/Input/index.tsx +36 -29
  80. package/src/components/Select/_hooks/useSelectComposition.test.ts +47 -0
  81. package/src/components/Select/_hooks/useSelectComposition.ts +6 -2
  82. package/src/components/Select/_hooks/useSelectInputOptions.ts +6 -9
  83. package/src/components/Slider/index.tsx +22 -12
  84. package/src/components/Table/_hooks/useAutoScrolling.test.tsx +95 -0
  85. package/src/components/Table/_hooks/useAutoScrolling.ts +16 -7
  86. package/src/components/Upload/_hooks/useUploadFileList.ts +6 -2
  87. package/src/components/Upload/_hooks/useUploadOperations.ts +7 -2
  88. package/src/hooks/useLatestRef.ts +31 -0
  89. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.test.tsx +56 -0
  90. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.ts +8 -2
@@ -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);
@@ -0,0 +1,189 @@
1
+ import React, { useState } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { makeAutoObservable } from "mobx";
5
+ import { observer } from "mobx-react-lite";
6
+
7
+ import Input from ".";
8
+ import Slider from "../Slider";
9
+
10
+ /**
11
+ * `Input` / `Password` / `Search` / `Number` / `Slider` 与 `TextArea` 是同一套写法,
12
+ * 也曾是同一个缺陷:`onChange` 进 effect 依赖数组、effect 体内又调它。
13
+ * 这里逐个盯住「消费方传内联箭头也不会死循环」这条底线。
14
+ */
15
+
16
+ class Store {
17
+ private _v = "";
18
+ constructor() {
19
+ makeAutoObservable(this);
20
+ }
21
+ get v() {
22
+ return this._v;
23
+ }
24
+ set = (v: string) => {
25
+ this._v = v;
26
+ };
27
+ }
28
+
29
+ const type = (el: HTMLElement, value: string) =>
30
+ fireEvent.change(el, { target: { value } });
31
+
32
+ const cases: Array<[string, React.FC<{ value: string; onChange: (v: string) => void }>]> = [
33
+ ["Input", (p) => <Input {...p} />],
34
+ ["Input.Password", (p) => <Input.Password {...p} />],
35
+ ["Input.Search", (p) => <Input.Search {...p} />],
36
+ ];
37
+
38
+ describe.each(cases)("%s", (name, Cmp) => {
39
+ it("父级是 MobX observer、又传内联箭头时不进入无限循环", async () => {
40
+ const store = new Store();
41
+ const spy = vi.fn();
42
+ const Page = observer(() => (
43
+ <Cmp
44
+ value={store.v}
45
+ onChange={(v) => {
46
+ spy(v);
47
+ store.set(v);
48
+ }}
49
+ />
50
+ ));
51
+
52
+ render(<Page />);
53
+ const el = document.querySelector("input") as HTMLInputElement;
54
+ type(el, "a");
55
+
56
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("a"));
57
+ await new Promise((r) => setTimeout(r, 100));
58
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
59
+ expect(store.v).toBe("a");
60
+ });
61
+
62
+ it("受控回写、外部改值、组字期间不通知", async () => {
63
+ const spy = vi.fn();
64
+ const Page = () => {
65
+ const [v, setV] = useState("");
66
+
67
+ return (
68
+ <Cmp
69
+ value={v}
70
+ onChange={(next) => {
71
+ spy(next);
72
+ setV(next);
73
+ }}
74
+ />
75
+ );
76
+ };
77
+
78
+ render(<Page />);
79
+ const el = document.querySelector("input") as HTMLInputElement;
80
+
81
+ fireEvent.compositionStart(el);
82
+ type(el, "n");
83
+ await new Promise((r) => setTimeout(r, 20));
84
+ expect(spy).not.toHaveBeenCalled();
85
+
86
+ fireEvent.compositionEnd(el, { currentTarget: el });
87
+ type(el, "你");
88
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("你"));
89
+ expect(el.value).toBe("你");
90
+ });
91
+ });
92
+
93
+ describe("Input", () => {
94
+ it("escapeCharacters:对内显示原文,对外给转义值", async () => {
95
+ const spy = vi.fn();
96
+ render(<Input escapeCharacters={[","]} onChange={spy} />);
97
+ const el = screen.getByRole("textbox") as HTMLInputElement;
98
+
99
+ type(el, "a,b");
100
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("a\\,b"));
101
+ expect(el.value).toBe("a,b");
102
+ expect(spy.mock.calls.length).toBe(1);
103
+ });
104
+
105
+ it("外部改 value 会同步进输入框,且不额外触发 onChange", async () => {
106
+ const spy = vi.fn();
107
+ const { rerender } = render(<Input value="a" onChange={spy} />);
108
+ const el = screen.getByRole("textbox") as HTMLInputElement;
109
+ expect(el.value).toBe("a");
110
+
111
+ rerender(<Input value="b" onChange={spy} />);
112
+ await waitFor(() => expect(el.value).toBe("b"));
113
+ expect(spy).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("内联 getRef 只在挂载时交一次 ref", async () => {
117
+ const seen: unknown[] = [];
118
+ const Page = () => {
119
+ const [, setTick] = useState(0);
120
+
121
+ return (
122
+ <Input
123
+ getRef={(r) => {
124
+ seen.push(r);
125
+ setTick((n) => n + 1);
126
+ }}
127
+ />
128
+ );
129
+ };
130
+
131
+ render(<Page />);
132
+ await new Promise((r) => setTimeout(r, 100));
133
+ expect(seen.length).toBe(1);
134
+ });
135
+ });
136
+
137
+ describe("Input.Number", () => {
138
+ it("父级把值转成数字回写、又传内联箭头时不进入无限循环", async () => {
139
+ const spy = vi.fn();
140
+ const Page = () => {
141
+ const [v, setV] = useState<string | number>("");
142
+
143
+ return (
144
+ <Input.Number
145
+ value={v}
146
+ onChange={(next) => {
147
+ spy(next);
148
+ // 模拟上游把值转成数字(FormItem 的默认行为),制造一个回环
149
+ setV(next === "" ? "" : Number(next));
150
+ }}
151
+ />
152
+ );
153
+ };
154
+
155
+ render(<Page />);
156
+ const el = document.querySelector("input") as HTMLInputElement;
157
+
158
+ type(el, "1");
159
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("1"));
160
+ await new Promise((r) => setTimeout(r, 100));
161
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
162
+ expect(el.value).toBe("1");
163
+ });
164
+ });
165
+
166
+ describe("Slider", () => {
167
+ it("内联箭头不循环", async () => {
168
+ const spy = vi.fn();
169
+ const Page = () => {
170
+ const [v, setV] = useState(0);
171
+
172
+ return (
173
+ <Slider
174
+ value={v}
175
+ onChange={(next) => {
176
+ spy(next);
177
+ setV(next as number);
178
+ }}
179
+ />
180
+ );
181
+ };
182
+
183
+ const { container } = render(<Page />);
184
+ expect(container.querySelector(".ant-slider")).toBeTruthy();
185
+ await new Promise((r) => setTimeout(r, 50));
186
+ // 没有交互就不该有任何通知
187
+ expect(spy).not.toHaveBeenCalled();
188
+ });
189
+ });
@@ -14,7 +14,7 @@ import TextArea, { TextAreaProps } from "./TextArea";
14
14
  import classNames from "classnames";
15
15
  import styles from "./index.module.scss";
16
16
  import RangeInput, { RangeInputProps } from "./Range";
17
- import { useDebounceEffect } from "ahooks";
17
+ import { useLatestRef } from "../../hooks/useLatestRef";
18
18
 
19
19
  export interface InputProps extends Omit<
20
20
  AntdInputProps,
@@ -54,8 +54,11 @@ const Input: InputFC = (props) => {
54
54
  escapeCharacters,
55
55
  ...inputConfig
56
56
  } = props;
57
- const [isComposing, setComposing] = useState<boolean>(false);
58
57
  const ref = useRef<InputRef>(null);
58
+ const getRefRef = useLatestRef(getRef);
59
+ // 输入法组字期间不往外通知。放 ref 不放 state:它只决定「要不要发通知」,
60
+ // 不参与渲染,而且必须在同一次事件里立刻生效
61
+ const composingRef = useRef(false);
59
62
 
60
63
  // Handle escaping: if the value contains characters listed in escapeCharacters, prefix them with an escape
61
64
  const escapeValue = useCallback(
@@ -143,25 +146,23 @@ const Input: InputFC = (props) => {
143
146
  const initialValue = getInitialValue();
144
147
 
145
148
  const [_value, setValue] = useState<string>(initialValue);
146
- const [lastValue, setLastValue] = useState<string>(initialValue);
147
149
  const prevValueRef = useRef<typeof value>(undefined);
148
-
149
- useDebounceEffect(
150
- () => {
151
- if (!isComposing && _value !== lastValue) {
152
- const trimmedValue = _value.trim();
153
- const finalValue = trimmedValue === "" ? "" : _value;
154
- setLastValue(finalValue);
155
- // If escapeCharacters is set and the value matches, return the escaped value
156
- const escapedValue = escapeValue(finalValue);
157
- onChange?.(escapedValue);
158
- }
159
- },
160
- [_value, isComposing, lastValue, onChange, escapeValue],
161
- {
162
- wait: 10,
163
- },
164
- );
150
+ // 最近一次通知出去的原文(未转义)。只用来去掉 compositionend 与 input
151
+ // 两个事件的重复通知
152
+ const notifiedRef = useRef<string>(initialValue);
153
+
154
+ /**
155
+ * `onChange` 是「用户改了输入」这个**事件**的通知,不是从 state 推导出来的结果,
156
+ * 所以在事件处理里发,不在 effect 里发。详见 `TextArea/index.tsx` 里那段说明——
157
+ * 旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
158
+ * 会让传内联箭头的消费方死循环。
159
+ */
160
+ const notify = (next: string) => {
161
+ if (next === notifiedRef.current) return;
162
+ notifiedRef.current = next;
163
+ // 全是空白等同于空;escapeCharacters 命中时对外给转义后的值
164
+ onChange?.(escapeValue(next.trim() === "" ? "" : next));
165
+ };
165
166
 
166
167
  useEffect(() => {
167
168
  // Update internal state only when the external value prop actually changes
@@ -173,19 +174,21 @@ const Input: InputFC = (props) => {
173
174
  typeof value === "number" ? `${value}` : value?.toString();
174
175
  // Unescape before displaying
175
176
  const newValue = unescapeValue(rawValue);
177
+ // 外部把值改了,之前通知过什么就不作数了,重新以外部值为准
178
+ notifiedRef.current = newValue;
176
179
  setValue(newValue);
177
- setLastValue(newValue);
178
180
  } else {
179
181
  // Clear only on initialization or when explicitly set to undefined externally
182
+ notifiedRef.current = "";
180
183
  setValue("");
181
- setLastValue("");
182
184
  }
183
185
  }
184
186
  }, [value, unescapeValue]);
185
187
 
188
+ // 只在挂载时把 ref 交出去。`getRef` 进依赖数组同样会被内联箭头带着每次渲染重跑
186
189
  useEffect(() => {
187
- getRef?.(ref.current);
188
- }, [getRef]);
190
+ getRefRef.current?.(ref.current);
191
+ }, [getRefRef]);
189
192
 
190
193
  return (
191
194
  <Tooltip placement="topLeft" {...tooltip}>
@@ -193,11 +196,14 @@ const Input: InputFC = (props) => {
193
196
  ref={ref}
194
197
  disabled={disabled}
195
198
  placeholder={disabled ? "" : placeholder}
196
- onCompositionStart={() => setComposing(true)}
197
- onCompositionEnd={() => {
198
- setTimeout(() => {
199
- setComposing(false);
200
- }, 1);
199
+ onCompositionStart={() => {
200
+ composingRef.current = true;
201
+ }}
202
+ onCompositionEnd={(e) => {
203
+ composingRef.current = false;
204
+ // 组完字的那一下自己发通知。浏览器之间 compositionend 与 input 的先后
205
+ // 不一致,`notify` 里按原文去重,两种顺序都只会发出一次
206
+ notify(unescapeValue(e.currentTarget.value));
201
207
  }}
202
208
  value={_value}
203
209
  onChange={(e) => {
@@ -205,6 +211,7 @@ const Input: InputFC = (props) => {
205
211
  const inputValue = e.target.value;
206
212
  const unescapedInput = unescapeValue(inputValue);
207
213
  setValue(unescapedInput);
214
+ if (!composingRef.current) notify(unescapedInput);
208
215
  }}
209
216
  className={classNames(styles.antdInput, className)}
210
217
  type={type}