@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.
- package/es/components/ChainGraph/_hooks/useChainGraphData.js +29 -8
- package/es/components/ChainGraph/_hooks/useChainGraphLayout.js +14 -3
- package/es/components/Chart/Bar/index.js +49 -19
- package/es/components/Chart/Bubble/index.js +21 -6
- package/es/components/Chart/Common/index.js +7 -2
- package/es/components/Chart/Heatmap/index.js +21 -6
- package/es/components/Chart/Line/index.js +49 -19
- package/es/components/Chart/Pie/Pie3D/index.js +22 -6
- package/es/components/Chart/Pie/index.js +21 -6
- package/es/components/Chart/Sankey/index.js +11 -2
- package/es/components/Chart/Tree/index.js +11 -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/useSelectComposition.js +6 -2
- package/es/components/Select/_hooks/useSelectInputOptions.js +8 -2
- package/es/components/Slider/index.js +24 -15
- package/es/components/Table/_hooks/useAutoScrolling.js +16 -7
- 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/es/layout/Menu/_hooks/useOnlyLvOneMenu.js +9 -2
- package/lib/components/ChainGraph/_hooks/useChainGraphData.js +24 -7
- package/lib/components/ChainGraph/_hooks/useChainGraphLayout.js +12 -2
- package/lib/components/Chart/Bar/index.js +45 -18
- package/lib/components/Chart/Bubble/index.js +19 -6
- package/lib/components/Chart/Common/index.js +6 -2
- package/lib/components/Chart/Heatmap/index.js +19 -6
- package/lib/components/Chart/Line/index.js +45 -18
- package/lib/components/Chart/Pie/Pie3D/index.js +20 -6
- package/lib/components/Chart/Pie/index.js +19 -6
- package/lib/components/Chart/Sankey/index.js +10 -2
- package/lib/components/Chart/Tree/index.js +10 -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/useSelectComposition.js +6 -2
- package/lib/components/Select/_hooks/useSelectInputOptions.js +7 -2
- package/lib/components/Slider/index.js +23 -11
- package/lib/components/Table/_hooks/useAutoScrolling.js +16 -7
- 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/lib/layout/Menu/_hooks/useOnlyLvOneMenu.js +8 -2
- package/package.json +9 -3
- package/src/__tests__/callbackDepsGuard.test.ts +119 -0
- package/src/__tests__/setup.ts +26 -0
- package/src/components/ChainGraph/_hooks/callbacks.test.tsx +155 -0
- package/src/components/ChainGraph/_hooks/useChainGraphData.ts +38 -10
- package/src/components/ChainGraph/_hooks/useChainGraphLayout.ts +16 -3
- package/src/components/Chart/Bar/index.tsx +59 -21
- package/src/components/Chart/Bubble/index.tsx +22 -7
- package/src/components/Chart/Common/index.tsx +7 -2
- package/src/components/Chart/Heatmap/index.tsx +25 -6
- package/src/components/Chart/Line/index.tsx +59 -21
- package/src/components/Chart/Pie/Pie3D/index.tsx +22 -7
- package/src/components/Chart/Pie/index.tsx +22 -7
- package/src/components/Chart/Sankey/index.tsx +10 -4
- package/src/components/Chart/Tree/index.tsx +10 -4
- package/src/components/Chart/callbacks.test.tsx +315 -0
- 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/useSelectComposition.test.ts +47 -0
- package/src/components/Select/_hooks/useSelectComposition.ts +6 -2
- package/src/components/Select/_hooks/useSelectInputOptions.ts +6 -9
- package/src/components/Slider/index.tsx +22 -12
- package/src/components/Table/_hooks/useAutoScrolling.test.tsx +95 -0
- package/src/components/Table/_hooks/useAutoScrolling.ts +16 -7
- 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
- package/src/layout/Menu/_hooks/useOnlyLvOneMenu.test.tsx +56 -0
- package/src/layout/Menu/_hooks/useOnlyLvOneMenu.ts +8 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { act, render } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 这一组用例盯的是同一条缺陷:**回调 prop 进 effect 依赖数组**。
|
|
7
|
+
*
|
|
8
|
+
* 消费方写内联箭头(`onXxx={(v) => setV(v)}`)时每次渲染都是新引用,
|
|
9
|
+
* 回调进依赖数组 → effect 重跑 → 重新 setOption / 重新注册监听 / 再发一次通知,
|
|
10
|
+
* 通知里再 setState 就是死循环。
|
|
11
|
+
*
|
|
12
|
+
* 但这些回调同时还被当作「要不要注册这个监听」的真值判断,
|
|
13
|
+
* 所以修法不能是「把回调从依赖数组里删掉」了事——
|
|
14
|
+
* 「从无到有传入回调」必须仍然能正确注册,这里每个组件都单独验一条。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Handler = (params: unknown) => void;
|
|
18
|
+
|
|
19
|
+
const chart = {
|
|
20
|
+
setOption: vi.fn(),
|
|
21
|
+
resize: vi.fn(),
|
|
22
|
+
dispose: vi.fn(),
|
|
23
|
+
getDataURL: vi.fn(() => "data:image/png;base64,AAAA"),
|
|
24
|
+
getOption: vi.fn(() => ({ dataZoom: [{ start: 0, end: 100 }] })),
|
|
25
|
+
on: vi.fn(),
|
|
26
|
+
off: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
const handlers = new Map<string, Set<Handler>>();
|
|
29
|
+
|
|
30
|
+
chart.on.mockImplementation(((event: string, handler: Handler) => {
|
|
31
|
+
if (!handlers.has(event)) handlers.set(event, new Set());
|
|
32
|
+
handlers.get(event)!.add(handler);
|
|
33
|
+
}) as never);
|
|
34
|
+
chart.off.mockImplementation(((event: string, handler?: Handler) => {
|
|
35
|
+
if (!handler) handlers.delete(event);
|
|
36
|
+
else handlers.get(event)?.delete(handler);
|
|
37
|
+
}) as never);
|
|
38
|
+
|
|
39
|
+
const emit = (event: string, params?: unknown) => {
|
|
40
|
+
act(() => {
|
|
41
|
+
handlers.get(event)?.forEach((h) => h(params));
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
const listenerCount = (event: string) => handlers.get(event)?.size ?? 0;
|
|
45
|
+
|
|
46
|
+
vi.mock("echarts", () => ({
|
|
47
|
+
init: () => chart,
|
|
48
|
+
use: () => {},
|
|
49
|
+
registerTheme: () => {},
|
|
50
|
+
graphic: {},
|
|
51
|
+
}));
|
|
52
|
+
vi.mock("echarts-gl", () => ({}));
|
|
53
|
+
|
|
54
|
+
import ChartBar from "./Bar";
|
|
55
|
+
import ChartLine from "./Line";
|
|
56
|
+
import ChartSankey from "./Sankey";
|
|
57
|
+
import ChartTree from "./Tree";
|
|
58
|
+
import ChartPie from "./Pie";
|
|
59
|
+
|
|
60
|
+
// 容器必须量得到尺寸,否则 useContainerReady 不放行 echarts.init
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
handlers.clear();
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
chart.getOption.mockImplementation(() => ({
|
|
65
|
+
dataZoom: [{ start: 0, end: 100 }],
|
|
66
|
+
}));
|
|
67
|
+
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
|
|
68
|
+
width: 600,
|
|
69
|
+
height: 400,
|
|
70
|
+
top: 0,
|
|
71
|
+
left: 0,
|
|
72
|
+
right: 600,
|
|
73
|
+
bottom: 400,
|
|
74
|
+
x: 0,
|
|
75
|
+
y: 0,
|
|
76
|
+
toJSON: () => ({}),
|
|
77
|
+
} as DOMRect);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const X_AXIS = ["一", "二", "三", "四"];
|
|
81
|
+
const SERIES = [{ name: "A", value: [1, 2, 3, 4] }];
|
|
82
|
+
const SANKEY_NODES = [{ name: "a" }, { name: "b" }];
|
|
83
|
+
const SANKEY_LINKS = [{ source: "a", target: "b" }];
|
|
84
|
+
const TREE_DATA = [{ name: "root", value: 1, children: [] }];
|
|
85
|
+
const PIE_DATA = [{ name: "a", value: 1 }];
|
|
86
|
+
|
|
87
|
+
describe("Chart.Bar / Chart.Line 的回调 prop", () => {
|
|
88
|
+
const cases = [
|
|
89
|
+
["Chart.Bar", ChartBar],
|
|
90
|
+
["Chart.Line", ChartLine],
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
93
|
+
for (const [name, Comp] of cases) {
|
|
94
|
+
describe(name, () => {
|
|
95
|
+
it("内联 onDataZoomWindowChanged 写回 state 不会死循环,窗口没变也不重复通知", () => {
|
|
96
|
+
const spy = vi.fn();
|
|
97
|
+
|
|
98
|
+
function Consumer() {
|
|
99
|
+
const [, setWin] = useState<unknown>(null);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Comp
|
|
103
|
+
xAxisData={X_AXIS}
|
|
104
|
+
seriesData={SERIES}
|
|
105
|
+
// 内联箭头:每次渲染都是新引用
|
|
106
|
+
onDataZoomWindowChanged={(w) => {
|
|
107
|
+
spy(w);
|
|
108
|
+
setWin(w);
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
render(<Consumer />);
|
|
115
|
+
|
|
116
|
+
// 初次同步一次窗口;进了循环这里会是几十上百次
|
|
117
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(spy).toHaveBeenCalledWith({ startIndex: 0, endIndex: 3 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("onClick 换引用不重复注册监听,且调到的是最新的那个", () => {
|
|
122
|
+
const first = vi.fn();
|
|
123
|
+
const second = vi.fn();
|
|
124
|
+
|
|
125
|
+
const { rerender } = render(
|
|
126
|
+
<Comp xAxisData={X_AXIS} seriesData={SERIES} onClick={first} />,
|
|
127
|
+
);
|
|
128
|
+
expect(listenerCount("click")).toBe(1);
|
|
129
|
+
|
|
130
|
+
rerender(
|
|
131
|
+
<Comp xAxisData={X_AXIS} seriesData={SERIES} onClick={second} />,
|
|
132
|
+
);
|
|
133
|
+
// 换引用不该拆装监听,更不该重新 setOption
|
|
134
|
+
expect(listenerCount("click")).toBe(1);
|
|
135
|
+
|
|
136
|
+
emit("click", { name: "x" });
|
|
137
|
+
expect(first).not.toHaveBeenCalled();
|
|
138
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("onLegendSelectChanged 从无到有传入时仍会注册", () => {
|
|
142
|
+
const { rerender } = render(
|
|
143
|
+
<Comp xAxisData={X_AXIS} seriesData={SERIES} />,
|
|
144
|
+
);
|
|
145
|
+
// 没传就不注册
|
|
146
|
+
expect(listenerCount("legendselectchanged")).toBe(0);
|
|
147
|
+
|
|
148
|
+
const spy = vi.fn();
|
|
149
|
+
rerender(
|
|
150
|
+
<Comp
|
|
151
|
+
xAxisData={X_AXIS}
|
|
152
|
+
seriesData={SERIES}
|
|
153
|
+
onLegendSelectChanged={spy}
|
|
154
|
+
/>,
|
|
155
|
+
);
|
|
156
|
+
expect(listenerCount("legendselectchanged")).toBe(1);
|
|
157
|
+
|
|
158
|
+
emit("legendselectchanged", { selected: { A: false } });
|
|
159
|
+
expect(spy).toHaveBeenCalledWith({ A: false });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("onDataZoomWindowChanged 从无到有传入时仍会注册并同步一次窗口", () => {
|
|
163
|
+
const { rerender } = render(
|
|
164
|
+
<Comp xAxisData={X_AXIS} seriesData={SERIES} />,
|
|
165
|
+
);
|
|
166
|
+
expect(listenerCount("datazoom")).toBe(0);
|
|
167
|
+
|
|
168
|
+
const spy = vi.fn();
|
|
169
|
+
rerender(
|
|
170
|
+
<Comp
|
|
171
|
+
xAxisData={X_AXIS}
|
|
172
|
+
seriesData={SERIES}
|
|
173
|
+
onDataZoomWindowChanged={spy}
|
|
174
|
+
/>,
|
|
175
|
+
);
|
|
176
|
+
expect(listenerCount("datazoom")).toBe(1);
|
|
177
|
+
expect(spy).toHaveBeenCalledWith({ startIndex: 0, endIndex: 3 });
|
|
178
|
+
|
|
179
|
+
spy.mockClear();
|
|
180
|
+
emit("datazoom", { start: 25, end: 100 });
|
|
181
|
+
expect(spy).toHaveBeenCalledWith({ startIndex: 1, endIndex: 3 });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("传回调后又撤掉时监听会摘干净", () => {
|
|
185
|
+
const { rerender } = render(
|
|
186
|
+
<Comp
|
|
187
|
+
xAxisData={X_AXIS}
|
|
188
|
+
seriesData={SERIES}
|
|
189
|
+
onLegendSelectChanged={() => {}}
|
|
190
|
+
/>,
|
|
191
|
+
);
|
|
192
|
+
expect(listenerCount("legendselectchanged")).toBe(1);
|
|
193
|
+
|
|
194
|
+
rerender(<Comp xAxisData={X_AXIS} seriesData={SERIES} />);
|
|
195
|
+
expect(listenerCount("legendselectchanged")).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("Chart.Sankey / Chart.Tree 的 getImage", () => {
|
|
202
|
+
it("Sankey:getImage 换引用不重新 setOption,出图时调到的是最新的那个", () => {
|
|
203
|
+
const first = vi.fn();
|
|
204
|
+
const second = vi.fn();
|
|
205
|
+
|
|
206
|
+
const { rerender } = render(
|
|
207
|
+
<ChartSankey
|
|
208
|
+
seriesData={SANKEY_NODES}
|
|
209
|
+
seriesLinks={SANKEY_LINKS}
|
|
210
|
+
getImage={first}
|
|
211
|
+
/>,
|
|
212
|
+
);
|
|
213
|
+
const before = chart.setOption.mock.calls.length;
|
|
214
|
+
|
|
215
|
+
rerender(
|
|
216
|
+
<ChartSankey
|
|
217
|
+
seriesData={SANKEY_NODES}
|
|
218
|
+
seriesLinks={SANKEY_LINKS}
|
|
219
|
+
getImage={second}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
// 出图回调不是画图的输入,换引用不该让图重画
|
|
223
|
+
expect(chart.setOption.mock.calls.length).toBe(before);
|
|
224
|
+
|
|
225
|
+
emit("finished");
|
|
226
|
+
expect(first).not.toHaveBeenCalled();
|
|
227
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("Sankey:内联 getImage 把图存进 state 不会死循环", () => {
|
|
231
|
+
const spy = vi.fn();
|
|
232
|
+
|
|
233
|
+
function Consumer() {
|
|
234
|
+
const [, setImg] = useState("");
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<ChartSankey
|
|
238
|
+
seriesData={SANKEY_NODES}
|
|
239
|
+
seriesLinks={SANKEY_LINKS}
|
|
240
|
+
getImage={(img) => {
|
|
241
|
+
spy(img);
|
|
242
|
+
setImg(img);
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
render(<Consumer />);
|
|
249
|
+
emit("finished");
|
|
250
|
+
emit("finished");
|
|
251
|
+
|
|
252
|
+
// finished 触发几次就回调几次,不会自激放大
|
|
253
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("Tree:getImage 换引用不重新 setOption,出图时调到的是最新的那个", () => {
|
|
257
|
+
const first = vi.fn();
|
|
258
|
+
const second = vi.fn();
|
|
259
|
+
|
|
260
|
+
const { rerender } = render(
|
|
261
|
+
<ChartTree seriesData={TREE_DATA} getImage={first} />,
|
|
262
|
+
);
|
|
263
|
+
const before = chart.setOption.mock.calls.length;
|
|
264
|
+
|
|
265
|
+
rerender(<ChartTree seriesData={TREE_DATA} getImage={second} />);
|
|
266
|
+
expect(chart.setOption.mock.calls.length).toBe(before);
|
|
267
|
+
|
|
268
|
+
emit("finished");
|
|
269
|
+
expect(first).not.toHaveBeenCalled();
|
|
270
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("Tree:从无到有传入 getImage 时仍能拿到图", () => {
|
|
274
|
+
const spy = vi.fn();
|
|
275
|
+
const { rerender } = render(<ChartTree seriesData={TREE_DATA} />);
|
|
276
|
+
|
|
277
|
+
emit("finished");
|
|
278
|
+
expect(spy).not.toHaveBeenCalled();
|
|
279
|
+
|
|
280
|
+
rerender(<ChartTree seriesData={TREE_DATA} getImage={spy} />);
|
|
281
|
+
emit("finished");
|
|
282
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("Chart.Pie 的 onClick", () => {
|
|
287
|
+
it("换引用不重复注册,且调到最新引用", () => {
|
|
288
|
+
const first = vi.fn();
|
|
289
|
+
const second = vi.fn();
|
|
290
|
+
|
|
291
|
+
const { rerender } = render(
|
|
292
|
+
<ChartPie seriesData={PIE_DATA} onClick={first} />,
|
|
293
|
+
);
|
|
294
|
+
expect(listenerCount("click")).toBe(1);
|
|
295
|
+
|
|
296
|
+
rerender(<ChartPie seriesData={PIE_DATA} onClick={second} />);
|
|
297
|
+
expect(listenerCount("click")).toBe(1);
|
|
298
|
+
|
|
299
|
+
emit("click", { name: "a" });
|
|
300
|
+
expect(first).not.toHaveBeenCalled();
|
|
301
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("从无到有传入 onClick 时仍会注册", () => {
|
|
305
|
+
const spy = vi.fn();
|
|
306
|
+
const { rerender } = render(<ChartPie seriesData={PIE_DATA} />);
|
|
307
|
+
expect(listenerCount("click")).toBe(0);
|
|
308
|
+
|
|
309
|
+
rerender(<ChartPie seriesData={PIE_DATA} onClick={spy} />);
|
|
310
|
+
expect(listenerCount("click")).toBe(1);
|
|
311
|
+
|
|
312
|
+
emit("click", { name: "a" });
|
|
313
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./_components";
|
|
15
15
|
import classNames from "classnames";
|
|
16
16
|
import Button, { ButtonProps } from "../../Button";
|
|
17
|
+
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
17
18
|
export type { AgentToggleConfig } from "./_components";
|
|
18
19
|
|
|
19
20
|
export interface AgentConfig {
|
|
@@ -127,6 +128,10 @@ const ChatInput: React.FC<ChatInputProps> = (props) => {
|
|
|
127
128
|
// Use external state or internal state
|
|
128
129
|
const agentsState = isControlled ? externalAgentsState : internalAgentsState;
|
|
129
130
|
|
|
131
|
+
const onAgentsChangeRef = useLatestRef(onAgentsChange);
|
|
132
|
+
|
|
133
|
+
// 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
|
|
134
|
+
// 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
|
|
130
135
|
// When the model switches, sync the agents state (only in uncontrolled mode)
|
|
131
136
|
useEffect(() => {
|
|
132
137
|
if (isControlled) return; // In controlled mode, state is managed externally
|
|
@@ -136,12 +141,12 @@ const ChatInput: React.FC<ChatInputProps> = (props) => {
|
|
|
136
141
|
);
|
|
137
142
|
if (currentModel?.agents && currentModel.agents.length > 0) {
|
|
138
143
|
setInternalAgentsState(currentModel.agents);
|
|
139
|
-
|
|
144
|
+
onAgentsChangeRef.current?.(currentModel.agents);
|
|
140
145
|
}
|
|
141
146
|
}, [
|
|
142
147
|
modelConfig?.modelType,
|
|
143
148
|
modelConfig?.modelList,
|
|
144
|
-
|
|
149
|
+
onAgentsChangeRef,
|
|
145
150
|
isControlled,
|
|
146
151
|
]);
|
|
147
152
|
|
|
@@ -8,6 +8,7 @@ import * as AntdIcons from "@ant-design/icons";
|
|
|
8
8
|
import React, { useCallback, useEffect, useRef } from "react";
|
|
9
9
|
import classNames from "classnames";
|
|
10
10
|
import styles from "./index.module.scss";
|
|
11
|
+
import { useLatestRef } from "../../hooks/useLatestRef";
|
|
11
12
|
|
|
12
13
|
type AntdNamedIconComponent = React.ForwardRefExoticComponent<
|
|
13
14
|
{
|
|
@@ -73,9 +74,13 @@ const Icon = React.forwardRef<HTMLSpanElement, IconProps>((props, forwardedRef)
|
|
|
73
74
|
[forwardedRef]
|
|
74
75
|
);
|
|
75
76
|
|
|
77
|
+
const onRefRef = useLatestRef(onRef);
|
|
78
|
+
|
|
79
|
+
// 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
|
|
80
|
+
// 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
|
|
76
81
|
useEffect(() => {
|
|
77
|
-
|
|
78
|
-
}, [
|
|
82
|
+
onRefRef.current?.(ref);
|
|
83
|
+
}, [onRefRef, ref]);
|
|
79
84
|
|
|
80
85
|
const mergedStyle: React.CSSProperties = {
|
|
81
86
|
color,
|
|
@@ -8,8 +8,8 @@ import { CloseCircleFilled } from "@ant-design/icons";
|
|
|
8
8
|
|
|
9
9
|
import classNames from "classnames";
|
|
10
10
|
import styles from "./index.module.scss";
|
|
11
|
-
import { useDebounceEffect } from "ahooks";
|
|
12
11
|
import type { InputNumberRef } from "../../../types/antd";
|
|
12
|
+
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
13
13
|
|
|
14
14
|
export interface InputNumberProps extends Omit<
|
|
15
15
|
AntdInputNumberProps,
|
|
@@ -44,6 +44,7 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
|
|
|
44
44
|
// `getRef` keeps handing consumers an `HTMLInputElement` exactly as before.
|
|
45
45
|
const ref = useRef<InputNumberRef>(null);
|
|
46
46
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
const getRefRef = useLatestRef(getRef);
|
|
47
48
|
|
|
48
49
|
// On initialization, prefer value, then fall back to defaultValue
|
|
49
50
|
const initialValue =
|
|
@@ -58,22 +59,21 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
|
|
|
58
59
|
: "";
|
|
59
60
|
|
|
60
61
|
const [_value, setValue] = useState<string>(initialValue);
|
|
61
|
-
const [lastValue, setLastValue] = useState<string>(initialValue);
|
|
62
62
|
const prevValueRef = useRef<typeof value>(undefined);
|
|
63
|
+
// 最近一次通知出去的文本,代替原来那个用 state 记账的写法
|
|
64
|
+
const notifiedRef = useRef<string>(initialValue);
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
},
|
|
76
|
-
);
|
|
66
|
+
/**
|
|
67
|
+
* `onChange` 是「用户改了输入」这个**事件**的通知,不是从 state 推导出来的结果,
|
|
68
|
+
* 所以在事件处理里发,不在 effect 里发。详见 `TextArea/index.tsx` 里那段说明——
|
|
69
|
+
* 旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
|
|
70
|
+
* 会让传内联箭头的消费方死循环。
|
|
71
|
+
*/
|
|
72
|
+
const notify = (next: string) => {
|
|
73
|
+
if (next === notifiedRef.current) return;
|
|
74
|
+
notifiedRef.current = next;
|
|
75
|
+
onChange?.(next);
|
|
76
|
+
};
|
|
77
77
|
|
|
78
78
|
useEffect(() => {
|
|
79
79
|
// Update internal state only when the external value prop actually changes
|
|
@@ -96,17 +96,18 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
|
|
|
96
96
|
Number(value) === Number(_value);
|
|
97
97
|
|
|
98
98
|
if (sameNumber) {
|
|
99
|
-
//
|
|
100
|
-
|
|
99
|
+
// 只同步「已通知过的值」,不动正在编辑的文本
|
|
100
|
+
notifiedRef.current = _value;
|
|
101
101
|
} else if (value !== undefined && value !== null) {
|
|
102
102
|
const newValue =
|
|
103
103
|
typeof value === "number" ? `${value}` : value?.toString();
|
|
104
|
+
// 外部把值改了,之前通知过什么就不作数了,重新以外部值为准
|
|
105
|
+
notifiedRef.current = newValue;
|
|
104
106
|
setValue(newValue);
|
|
105
|
-
setLastValue(newValue);
|
|
106
107
|
} else {
|
|
107
108
|
// Clear only on initialization or when explicitly set to undefined/null externally
|
|
109
|
+
notifiedRef.current = "";
|
|
108
110
|
setValue("");
|
|
109
|
-
setLastValue("");
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
// `_value` 是刻意不进依赖的:这个 effect 只该在**外部** value 变化时跑,
|
|
@@ -114,15 +115,15 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
|
|
|
114
115
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
116
|
}, [value]);
|
|
116
117
|
|
|
118
|
+
// 只在挂载时把 ref 交出去。`getRef` 进依赖数组同样会被内联箭头带着每次渲染重跑
|
|
117
119
|
useEffect(() => {
|
|
118
|
-
|
|
119
|
-
}, [
|
|
120
|
+
getRefRef.current?.(ref.current);
|
|
121
|
+
}, [getRefRef]);
|
|
120
122
|
|
|
121
123
|
const handleClear = (e: React.MouseEvent) => {
|
|
122
124
|
e.stopPropagation();
|
|
123
125
|
setValue("");
|
|
124
|
-
|
|
125
|
-
onChange?.("");
|
|
126
|
+
notify("");
|
|
126
127
|
};
|
|
127
128
|
|
|
128
129
|
const showClear =
|
|
@@ -138,6 +139,7 @@ const InputNumber: React.FC<InputNumberProps> = (props) => {
|
|
|
138
139
|
? ""
|
|
139
140
|
: (typeof e === "number" ? e : e || "").toString();
|
|
140
141
|
setValue(newValue);
|
|
142
|
+
notify(newValue);
|
|
141
143
|
}}
|
|
142
144
|
className={classNames(styles.antdInput, className)}
|
|
143
145
|
controls={false}
|
|
@@ -6,6 +6,7 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
6
6
|
import { InputRef } from "antd/es/input";
|
|
7
7
|
import classNames from "classnames";
|
|
8
8
|
import styles from "./index.module.scss";
|
|
9
|
+
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
9
10
|
|
|
10
11
|
export interface PasswordProps
|
|
11
12
|
extends Omit<
|
|
@@ -19,8 +20,11 @@ export interface PasswordProps
|
|
|
19
20
|
const Password: React.FC<PasswordProps> = (props) => {
|
|
20
21
|
const { onChange, getRef, value, defaultValue, className, ...inputConfig } =
|
|
21
22
|
props;
|
|
22
|
-
const [isComposing, setComposing] = useState<boolean>(false);
|
|
23
23
|
const ref = useRef<InputRef>(null);
|
|
24
|
+
const getRefRef = useLatestRef(getRef);
|
|
25
|
+
// 输入法组字期间不往外通知。放 ref 不放 state:它只决定「要不要发通知」,
|
|
26
|
+
// 不参与渲染,而且必须在同一次事件里立刻生效
|
|
27
|
+
const composingRef = useRef(false);
|
|
24
28
|
|
|
25
29
|
// On initialization, prefer value, then fall back to defaultValue
|
|
26
30
|
const initialValue =
|
|
@@ -31,51 +35,58 @@ const Password: React.FC<PasswordProps> = (props) => {
|
|
|
31
35
|
: "";
|
|
32
36
|
|
|
33
37
|
const [_value, setValue] = useState<string>(initialValue);
|
|
34
|
-
const [lastValue, setLastValue] = useState<string>(initialValue);
|
|
35
38
|
const prevValueRef = useRef<typeof value>(undefined);
|
|
39
|
+
// 最近一次通知出去的原文。只用来去掉 compositionend 与 input 两个事件的重复通知
|
|
40
|
+
const notifiedRef = useRef<string>(initialValue);
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
64
|
-
}, [
|
|
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={() =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
62
|
-
}, [
|
|
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={() =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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) =>
|
|
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
|
);
|