@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
@@ -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}
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { SelectOption } from "..";
3
+ import { useLatestRef } from "../../../hooks/useLatestRef";
3
4
 
4
5
  interface UseSelectInputOptionsProps {
5
6
  searchValue: string;
@@ -20,19 +21,15 @@ export function useSelectInputOptions({
20
21
  optionsLength,
21
22
  }: UseSelectInputOptionsProps) {
22
23
  const [inputOptions, setInputOptions] = useState<SelectOption[]>([]);
24
+ const onSearchRef = useLatestRef(onSearch);
23
25
 
26
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
27
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
24
28
  useEffect(() => {
25
29
  if (!isComposing) {
26
- onSearch?.(searchValue);
30
+ onSearchRef.current?.(searchValue);
27
31
  }
28
- }, [
29
- inputOptions,
30
- isComposing,
31
- onChange,
32
- onSearch,
33
- optionsLength,
34
- searchValue,
35
- ]);
32
+ }, [inputOptions, isComposing, onSearchRef, optionsLength, searchValue]);
36
33
 
37
34
  return { inputOptions, setInputOptions };
38
35
  }
@@ -2,7 +2,7 @@ import {
2
2
  Slider as AntdSlider,
3
3
  SliderSingleProps as AntdSliderSingleProps,
4
4
  } from "antd";
5
- import React, { useEffect, useState } from "react";
5
+ import React, { useEffect, useRef, useState } from "react";
6
6
 
7
7
  import classNames from "classnames";
8
8
  import styles from "./index.module.scss";
@@ -14,27 +14,37 @@ export interface SliderProps extends AntdSliderSingleProps {
14
14
  const Slider: React.FC<SliderProps> = (props) => {
15
15
  const { className, value = 0, topValue, onChange, ...sliderConfig } = props;
16
16
  const [_value, setValue] = useState<number>(value);
17
- const [lastValue, setLastValue] = useState<number>(value);
17
+ // 最近一次通知出去的值,代替原来那个用 state 记账的写法
18
+ const notifiedRef = useRef<number>(value);
18
19
 
19
- useEffect(() => {
20
- if (_value !== lastValue) {
21
- setLastValue(_value);
22
- onChange?.(_value);
23
- }
24
- }, [lastValue, onChange, _value]);
20
+ /**
21
+ * `onChange` 是「用户拖了滑块」这个**事件**的通知,不是从 state 推导出来的结果,
22
+ * 所以在事件处理里发,不在 effect 里发。详见 `Input/TextArea/index.tsx` 里那段
23
+ * 说明——旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
24
+ * 会让传内联箭头的消费方死循环。
25
+ */
26
+ const handleChange = (next: number) => {
27
+ setValue(next);
28
+ if (next === notifiedRef.current) return;
29
+ notifiedRef.current = next;
30
+ onChange?.(next);
31
+ };
25
32
 
33
+ // 外部值(`value` / `topValue`)变了才回写,不跟着通知走
26
34
  useEffect(() => {
27
- if ((topValue || value) !== lastValue) {
28
- setValue(topValue || value);
35
+ const next = topValue || value;
36
+ if (next !== notifiedRef.current) {
37
+ notifiedRef.current = next;
38
+ setValue(next);
29
39
  }
30
- }, [lastValue, topValue, value]);
40
+ }, [topValue, value]);
31
41
 
32
42
  return (
33
43
  <AntdSlider
34
44
  className={classNames([styles.slider, className])}
35
45
  tooltip={{ open: false }}
36
46
  value={_value}
37
- onChange={setValue}
47
+ onChange={handleChange}
38
48
  {...sliderConfig}
39
49
  />
40
50
  );
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import { UploadFile } from "antd";
3
3
  import { deepCopy, Equal } from "hsu-utils";
4
4
  import { extractFileUrl } from "../_utils";
5
+ import { useLatestRef } from "../../../hooks/useLatestRef";
5
6
 
6
7
  interface UseUploadFileListProps {
7
8
  fileList?: UploadFile[];
@@ -19,7 +20,10 @@ export function useUploadFileList({
19
20
  }: UseUploadFileListProps) {
20
21
  const [_fileList, setFilelist] = useState<UploadFile[]>([]);
21
22
  const [lastFileList, setLastFileList] = useState<UploadFile[]>([]);
23
+ const onChangeRef = useLatestRef(onChange);
22
24
 
25
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
26
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
23
27
  useEffect(() => {
24
28
  if (rmFile) {
25
29
  const file = _fileList.find((item) => item.uid === rmFile);
@@ -27,14 +31,14 @@ export function useUploadFileList({
27
31
  if (file) {
28
32
  setFilelist(filteredList);
29
33
  setTimeout(() => {
30
- onChange?.({
34
+ onChangeRef.current?.({
31
35
  file,
32
36
  fileList: filteredList,
33
37
  });
34
38
  }, 100);
35
39
  }
36
40
  }
37
- }, [_fileList, onChange, rmFile]);
41
+ }, [_fileList, onChangeRef, rmFile]);
38
42
 
39
43
  useEffect(() => {
40
44
  if (!Equal.ObjEqual(fileList ?? [], lastFileList)) {
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { deepCopy, Equal } from "hsu-utils";
3
3
  import { UploadingList } from "..";
4
+ import { useLatestRef } from "../../../hooks/useLatestRef";
4
5
 
5
6
  interface UseUploadOperationsProps {
6
7
  onUploadingList?: (list: UploadingList) => void;
@@ -25,6 +26,8 @@ export function useUploadOperations({
25
26
  uploadingList: {},
26
27
  });
27
28
 
29
+ const onUploadingListRef = useLatestRef(onUploadingList);
30
+
28
31
  // Sync to the ref on update
29
32
  useEffect(() => {
30
33
  operationsRef.current.downloading = downloading;
@@ -34,12 +37,14 @@ export function useUploadOperations({
34
37
  operationsRef.current.uploadingList = uploadingList;
35
38
  }, [uploadingList]);
36
39
 
40
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
41
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
37
42
  useEffect(() => {
38
43
  if (!Equal.ObjEqual(uploadingList, lastUploadList)) {
39
- onUploadingList?.(deepCopy(uploadingList));
44
+ onUploadingListRef.current?.(deepCopy(uploadingList));
40
45
  setLastUploadList(deepCopy(uploadingList));
41
46
  }
42
- }, [uploadingList, lastUploadList, onUploadingList]);
47
+ }, [uploadingList, lastUploadList, onUploadingListRef]);
43
48
 
44
49
  // Cleanup function
45
50
  useEffect(() => {
@@ -0,0 +1,31 @@
1
+ import { useRef } from "react";
2
+
3
+ /**
4
+ * 把一个每次渲染都可能换引用的值(通常是回调 prop)装进一个**引用恒定**的 ref。
5
+ *
6
+ * 用途只有一个:让 effect 不必把回调放进依赖数组。
7
+ * 回调放进依赖数组、effect 体内又调它,是一条会让消费方死循环的写法——
8
+ * 消费方传内联箭头(`onChange={(v) => setX(v)}`,React 里最常见的写法)时
9
+ * 每次渲染都是新引用 → effect 重跑 → 调回调 → 父级 setState → 再渲染 →
10
+ * 又是新引用 …… 直到 React 抛 `Maximum update depth exceeded`。
11
+ *
12
+ * ```ts
13
+ * const onDoneRef = useLatestRef(onDone);
14
+ * useEffect(() => {
15
+ * const t = setTimeout(() => onDoneRef.current?.(), 1000);
16
+ * return () => clearTimeout(t);
17
+ * }, [onDoneRef]); // 只在挂载时跑一次,且永远调到最新的 onDone
18
+ * ```
19
+ *
20
+ * 注意:渲染期间就地赋值。React 官方对 ref 的约束是「不要在渲染中**读**可变值」,
21
+ * 写入最新的 props 是 useEffectEvent 落地前的通行做法(ahooks 的 `useLatest`、
22
+ * React 文档里的 `useEvent` polyfill 都是这么写的)。
23
+ */
24
+ export function useLatestRef<T>(value: T) {
25
+ const ref = useRef(value);
26
+ ref.current = value;
27
+
28
+ return ref;
29
+ }
30
+
31
+ export default useLatestRef;
@@ -28,6 +28,17 @@ import Layout from "@hsu-react/ui/es/layout";
28
28
 
29
29
  `meta.secondary` 标记的路由进入**二级菜单**:主菜单只显示到一级,点进去之后左侧换成该模块的次级菜单,顶部可用 `secondaryHeader` 放返回入口、标题或检索框。
30
30
 
31
+ ### 滚动与滚动条
32
+
33
+ `inline` 模式下本组件**自带滚动容器**:外层包一个撑满高度、自己不滚的宿主,菜单根 `overflow-y: auto` 负责滚。
34
+ 所以把 `Menu` 塞进任何一个**有确定高度**的容器(antd 的 `Layout.Sider`、或自己的 div)即可,不需要再补 `overflow` 规则;
35
+ 展开 / 收起子菜单的动画期间也不会有滚动条闪出来。宿主没有确定高度时菜单会被撑开、退回由页面滚,这时请给宿主一个高度。
36
+
37
+ 滚动条的**外观(显 / 隐 / 配色)本组件不管**,交给消费方的全局样式(`::-webkit-scrollbar` 那一套)。
38
+ 组件里刻意不写 `scrollbar-width` / `scrollbar-color`:**Chrome 121+ 一旦读到它们的非默认值,就会整套忽略该元素上的
39
+ `::-webkit-scrollbar` 规则**,库里无差别写一句就会悄悄废掉消费方全站的滚动条皮肤。库内其它地方若确实需要这两个属性,
40
+ 必须锁在 `@supports (-moz-appearance: none)` 里,只给 Firefox 看。
41
+
31
42
  ## API
32
43
 
33
44
  | 属性 | 说明 | 类型 | 默认值 |