@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.
- package/es/components/Chart/Bubble/index.js +7 -2
- package/es/components/Chart/Common/index.js +7 -2
- package/es/components/Chart/Heatmap/index.js +7 -2
- package/es/components/Chart/Pie/Pie3D/index.js +8 -2
- package/es/components/Chart/Pie/index.js +7 -2
- package/es/components/Chat/ChatInput/index.js +7 -2
- package/es/components/Icon/index.js +8 -2
- package/es/components/Input/Number/index.js +28 -21
- package/es/components/Input/Password/index.js +44 -36
- package/es/components/Input/Search/index.js +44 -36
- package/es/components/Input/TextArea/index.js +64 -42
- package/es/components/Input/index.js +40 -34
- package/es/components/Select/_hooks/useSelectInputOptions.js +8 -2
- package/es/components/Slider/index.js +24 -15
- package/es/components/Upload/_hooks/useUploadFileList.js +8 -2
- package/es/components/Upload/_hooks/useUploadOperations.js +8 -2
- package/es/hooks/useLatestRef.d.ts +24 -0
- package/es/hooks/useLatestRef.js +29 -0
- package/lib/components/Chart/Bubble/index.js +6 -2
- package/lib/components/Chart/Common/index.js +6 -2
- package/lib/components/Chart/Heatmap/index.js +6 -2
- package/lib/components/Chart/Pie/Pie3D/index.js +7 -2
- package/lib/components/Chart/Pie/index.js +6 -2
- package/lib/components/Chat/ChatInput/index.js +6 -2
- package/lib/components/Icon/index.js +7 -2
- package/lib/components/Input/Number/index.js +27 -18
- package/lib/components/Input/Password/index.js +40 -26
- package/lib/components/Input/Search/index.js +42 -26
- package/lib/components/Input/TextArea/index.js +54 -26
- package/lib/components/Input/index.js +37 -24
- package/lib/components/Select/_hooks/useSelectInputOptions.js +7 -2
- package/lib/components/Slider/index.js +23 -11
- package/lib/components/Upload/_hooks/useUploadFileList.js +7 -2
- package/lib/components/Upload/_hooks/useUploadOperations.js +7 -2
- package/lib/hooks/useLatestRef.d.ts +24 -0
- package/lib/hooks/useLatestRef.js +35 -0
- package/package.json +9 -3
- package/src/__tests__/setup.ts +26 -0
- package/src/components/Chart/Bubble/index.tsx +7 -2
- package/src/components/Chart/Common/index.tsx +7 -2
- package/src/components/Chart/Heatmap/index.tsx +7 -2
- package/src/components/Chart/Pie/Pie3D/index.tsx +7 -2
- package/src/components/Chart/Pie/index.tsx +7 -2
- package/src/components/Chat/ChatInput/index.tsx +7 -2
- package/src/components/Icon/index.tsx +7 -2
- package/src/components/Input/Number/index.tsx +25 -23
- package/src/components/Input/Password/index.tsx +37 -26
- package/src/components/Input/Search/index.tsx +39 -26
- package/src/components/Input/TextArea/index.test.tsx +208 -0
- package/src/components/Input/TextArea/index.tsx +56 -30
- package/src/components/Input/index.test.tsx +189 -0
- package/src/components/Input/index.tsx +36 -29
- package/src/components/Select/_hooks/useSelectInputOptions.ts +6 -9
- package/src/components/Slider/index.tsx +22 -12
- package/src/components/Upload/_hooks/useUploadFileList.ts +6 -2
- package/src/components/Upload/_hooks/useUploadOperations.ts +7 -2
- package/src/hooks/useLatestRef.ts +31 -0
|
@@ -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 {
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
188
|
-
}, [
|
|
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={() =>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
// 最近一次通知出去的值,代替原来那个用 state 记账的写法
|
|
18
|
+
const notifiedRef = useRef<number>(value);
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
35
|
+
const next = topValue || value;
|
|
36
|
+
if (next !== notifiedRef.current) {
|
|
37
|
+
notifiedRef.current = next;
|
|
38
|
+
setValue(next);
|
|
29
39
|
}
|
|
30
|
-
}, [
|
|
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={
|
|
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
|
-
|
|
34
|
+
onChangeRef.current?.({
|
|
31
35
|
file,
|
|
32
36
|
fileList: filteredList,
|
|
33
37
|
});
|
|
34
38
|
}, 100);
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
|
-
}, [_fileList,
|
|
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
|
-
|
|
44
|
+
onUploadingListRef.current?.(deepCopy(uploadingList));
|
|
40
45
|
setLastUploadList(deepCopy(uploadingList));
|
|
41
46
|
}
|
|
42
|
-
}, [uploadingList, lastUploadList,
|
|
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;
|