@hsu-react/ui 2.5.4 → 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 (46) 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 +15 -5
  5. package/es/components/Chart/Heatmap/index.js +15 -5
  6. package/es/components/Chart/Line/index.js +49 -19
  7. package/es/components/Chart/Pie/Pie3D/index.js +15 -5
  8. package/es/components/Chart/Pie/index.js +15 -5
  9. package/es/components/Chart/Sankey/index.js +11 -2
  10. package/es/components/Chart/Tree/index.js +11 -2
  11. package/es/components/Select/_hooks/useSelectComposition.js +6 -2
  12. package/es/components/Table/_hooks/useAutoScrolling.js +16 -7
  13. package/es/layout/Menu/_hooks/useOnlyLvOneMenu.js +9 -2
  14. package/lib/components/ChainGraph/_hooks/useChainGraphData.js +24 -7
  15. package/lib/components/ChainGraph/_hooks/useChainGraphLayout.js +12 -2
  16. package/lib/components/Chart/Bar/index.js +45 -18
  17. package/lib/components/Chart/Bubble/index.js +14 -5
  18. package/lib/components/Chart/Heatmap/index.js +14 -5
  19. package/lib/components/Chart/Line/index.js +45 -18
  20. package/lib/components/Chart/Pie/Pie3D/index.js +14 -5
  21. package/lib/components/Chart/Pie/index.js +14 -5
  22. package/lib/components/Chart/Sankey/index.js +10 -2
  23. package/lib/components/Chart/Tree/index.js +10 -2
  24. package/lib/components/Select/_hooks/useSelectComposition.js +6 -2
  25. package/lib/components/Table/_hooks/useAutoScrolling.js +16 -7
  26. package/lib/layout/Menu/_hooks/useOnlyLvOneMenu.js +8 -2
  27. package/package.json +1 -1
  28. package/src/__tests__/callbackDepsGuard.test.ts +119 -0
  29. package/src/components/ChainGraph/_hooks/callbacks.test.tsx +155 -0
  30. package/src/components/ChainGraph/_hooks/useChainGraphData.ts +38 -10
  31. package/src/components/ChainGraph/_hooks/useChainGraphLayout.ts +16 -3
  32. package/src/components/Chart/Bar/index.tsx +59 -21
  33. package/src/components/Chart/Bubble/index.tsx +15 -5
  34. package/src/components/Chart/Heatmap/index.tsx +19 -5
  35. package/src/components/Chart/Line/index.tsx +59 -21
  36. package/src/components/Chart/Pie/Pie3D/index.tsx +15 -5
  37. package/src/components/Chart/Pie/index.tsx +15 -5
  38. package/src/components/Chart/Sankey/index.tsx +10 -4
  39. package/src/components/Chart/Tree/index.tsx +10 -4
  40. package/src/components/Chart/callbacks.test.tsx +315 -0
  41. package/src/components/Select/_hooks/useSelectComposition.test.ts +47 -0
  42. package/src/components/Select/_hooks/useSelectComposition.ts +6 -2
  43. package/src/components/Table/_hooks/useAutoScrolling.test.tsx +95 -0
  44. package/src/components/Table/_hooks/useAutoScrolling.ts +16 -7
  45. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.test.tsx +56 -0
  46. 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
+ });
@@ -0,0 +1,47 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { renderHook } from "@testing-library/react";
3
+
4
+ import { useSelectComposition } from "./useSelectComposition";
5
+
6
+ /**
7
+ * 这里的 onSearch 只被当作「要不要监听输入法事件」的真值判断,effect 体从不调它。
8
+ * 它直接进依赖数组,消费方传内联箭头就会每渲染一次拆装一次 window 监听。
9
+ */
10
+ describe("useSelectComposition", () => {
11
+ let addSpy: ReturnType<typeof vi.spyOn>;
12
+
13
+ const countAdds = () =>
14
+ addSpy.mock.calls.filter(([type]) => type === "compositionstart").length;
15
+
16
+ beforeEach(() => {
17
+ addSpy = vi.spyOn(window, "addEventListener");
18
+ });
19
+ afterEach(() => {
20
+ addSpy.mockRestore();
21
+ });
22
+
23
+ it("没传 onSearch 不注册;从无到有传入时会注册", () => {
24
+ const { rerender } = renderHook(
25
+ ({ onSearch }: { onSearch?: (v: string) => void }) =>
26
+ useSelectComposition({ onSearch }),
27
+ { initialProps: {} as { onSearch?: (v: string) => void } },
28
+ );
29
+ expect(countAdds()).toBe(0);
30
+
31
+ rerender({ onSearch: () => {} });
32
+ expect(countAdds()).toBe(1);
33
+ });
34
+
35
+ it("onSearch 换引用不会反复拆装 window 监听", () => {
36
+ const { rerender } = renderHook(
37
+ ({ onSearch }: { onSearch?: (v: string) => void }) =>
38
+ useSelectComposition({ onSearch }),
39
+ { initialProps: { onSearch: () => {} } },
40
+ );
41
+ expect(countAdds()).toBe(1);
42
+
43
+ rerender({ onSearch: () => {} });
44
+ rerender({ onSearch: () => {} });
45
+ expect(countAdds()).toBe(1);
46
+ });
47
+ });
@@ -11,6 +11,7 @@ export function useSelectComposition({
11
11
  onSearch,
12
12
  }: UseSelectCompositionProps) {
13
13
  const [isComposing, setComposing] = useState<boolean>(false);
14
+ const hasOnSearch = !!onSearch;
14
15
 
15
16
  useEffect(() => {
16
17
  const compositionend = () => {
@@ -20,7 +21,7 @@ export function useSelectComposition({
20
21
  setComposing(true);
21
22
  };
22
23
 
23
- if (onSearch) {
24
+ if (hasOnSearch) {
24
25
  window.addEventListener("compositionstart", compositionstart);
25
26
  window.addEventListener("compositionend", compositionend);
26
27
  }
@@ -29,7 +30,10 @@ export function useSelectComposition({
29
30
  window.removeEventListener("compositionstart", compositionstart);
30
31
  window.removeEventListener("compositionend", compositionend);
31
32
  };
32
- }, [onSearch]);
33
+ // onSearch 在这里只被当作「要不要监听输入法事件」的真值判断,effect 体从不调它。
34
+ // 直接进依赖数组的话,消费方传内联箭头就会每渲染一次拆一次、装一次 window 监听;
35
+ // 只取「有没有传」这个布尔量,从无到有时照样会重新注册。
36
+ }, [hasOnSearch]);
33
37
 
34
38
  return { isComposing };
35
39
  }
@@ -0,0 +1,95 @@
1
+ import React, { useRef } from "react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, render, waitFor } from "@testing-library/react";
4
+
5
+ import useAutoScrolling from "./useAutoScrolling";
6
+
7
+ /**
8
+ * onAutoScrollEndAdd 在这个 hook 里身兼两职:既是「有没有加载更多」的开关,
9
+ * 又是真正去加载的动作。整条 rAF 循环挂在它的引用上(loop 依赖它、启停 effect 依赖 loop),
10
+ * 消费方传内联箭头就会每渲染一次重启一次滚动循环 —— 表格滚到一半被打回起点。
11
+ */
12
+
13
+ const DATA = [{ key: "1" }, { key: "2" }];
14
+
15
+ function Host({
16
+ onAutoScrollEndAdd,
17
+ }: {
18
+ onAutoScrollEndAdd?: () => Promise<boolean>;
19
+ }) {
20
+ const ref = useRef<HTMLDivElement>(null);
21
+
22
+ useAutoScrolling({
23
+ autoScrolling: true,
24
+ ref,
25
+ dataSource: DATA,
26
+ onAutoScrollEndAdd,
27
+ });
28
+
29
+ return (
30
+ <div ref={ref}>
31
+ <div className="ant-table-body">
32
+ <table>
33
+ <tbody className="ant-table-tbody" />
34
+ </table>
35
+ </div>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ describe("useAutoScrolling", () => {
41
+ let cancelSpy: ReturnType<typeof vi.spyOn>;
42
+
43
+ beforeEach(() => {
44
+ // 让 rAF 不真的跑,cancelAnimationFrame 就只会由 effect 的 cleanup 触发,
45
+ // 于是「循环有没有被重启」可以直接数出来
46
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation(
47
+ () => 1 as unknown as number,
48
+ );
49
+ cancelSpy = vi
50
+ .spyOn(window, "cancelAnimationFrame")
51
+ .mockImplementation(() => {});
52
+ });
53
+ afterEach(() => {
54
+ vi.restoreAllMocks();
55
+ });
56
+
57
+ /** hook 靠 MutationObserver 把自己标成 ready,这里手动改一次 DOM 触发它 */
58
+ const makeReady = async (container: HTMLElement) => {
59
+ await act(async () => {
60
+ container
61
+ .querySelector(".ant-table-tbody")!
62
+ .appendChild(document.createElement("tr"));
63
+ await Promise.resolve();
64
+ });
65
+ await waitFor(() =>
66
+ expect(window.requestAnimationFrame).toHaveBeenCalled(),
67
+ );
68
+ };
69
+
70
+ it("onAutoScrollEndAdd 换引用不会重启滚动循环", async () => {
71
+ const { container, rerender } = render(
72
+ <Host onAutoScrollEndAdd={() => Promise.resolve(true)} />,
73
+ );
74
+ await makeReady(container);
75
+
76
+ const before = cancelSpy.mock.calls.length;
77
+
78
+ rerender(<Host onAutoScrollEndAdd={() => Promise.resolve(true)} />);
79
+ rerender(<Host onAutoScrollEndAdd={() => Promise.resolve(true)} />);
80
+
81
+ expect(cancelSpy.mock.calls.length).toBe(before);
82
+ });
83
+
84
+ it("从无到有传入 onAutoScrollEndAdd 时会接上加载更多(循环按新开关重建一次)", async () => {
85
+ const { container, rerender } = render(<Host />);
86
+ await makeReady(container);
87
+
88
+ const before = cancelSpy.mock.calls.length;
89
+
90
+ rerender(<Host onAutoScrollEndAdd={() => Promise.resolve(true)} />);
91
+
92
+ // 开关从 false 变 true,循环必须按新语义重建,否则「加载更多」永远接不上
93
+ expect(cancelSpy.mock.calls.length).toBeGreaterThan(before);
94
+ });
95
+ });
@@ -1,6 +1,7 @@
1
1
  import { useMutationObserver } from "ahooks";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { TableProps } from "..";
4
+ import { useLatestRef } from "../../../hooks/useLatestRef";
4
5
 
5
6
  const DEFAULT_INTERVAL = 2000;
6
7
  const DEFAULT_SPEED = 25;
@@ -39,6 +40,13 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
39
40
  autoScrollingOffset = 0,
40
41
  } = props;
41
42
 
43
+ // onAutoScrollEndAdd 在这里身兼两职:既是「有没有加载更多」的开关,又是真正去加载的动作。
44
+ // 整个 rAF 循环(loop → 依赖它 → 启停 effect 依赖 loop)都挂在它的引用上,
45
+ // 消费方传内联箭头就会每渲染一次重启一次滚动循环 —— 表格滚到一半被打回起点。
46
+ // 拆成两半:开关看布尔量(从无到有传入时仍会正确接上加载更多),动作取 ref 里的最新引用。
47
+ const onAutoScrollEndAddRef = useLatestRef(onAutoScrollEndAdd);
48
+ const hasOnAutoScrollEndAdd = !!onAutoScrollEndAdd;
49
+
42
50
  const validSpeed =
43
51
  autoScrollingSpeed > 0 ? autoScrollingSpeed : DEFAULT_SPEED;
44
52
  const smoothStep = (validSpeed * SMOOTH_SCROLL_TICK) / 1000;
@@ -324,12 +332,13 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
324
332
  );
325
333
 
326
334
  const runLoadMore = useCallback(() => {
327
- if (!onAutoScrollEndAdd || pendingLoadMoreRef.current) return;
335
+ const loadMore = onAutoScrollEndAddRef.current;
336
+ if (!loadMore || pendingLoadMoreRef.current) return;
328
337
  pendingLoadMoreRef.current = true;
329
338
  phaseRef.current = "loading";
330
339
  const token = ++loadTokenRef.current;
331
340
 
332
- onAutoScrollEndAdd()
341
+ loadMore()
333
342
  .then((hasMore) => {
334
343
  if (token !== loadTokenRef.current) return;
335
344
  const currentBody = getBody();
@@ -352,7 +361,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
352
361
  if (token !== loadTokenRef.current) return;
353
362
  pendingLoadMoreRef.current = false;
354
363
  });
355
- }, [autoScrollLoop, getBody, onAutoScrollEndAdd]);
364
+ }, [autoScrollLoop, getBody, onAutoScrollEndAddRef]);
356
365
 
357
366
  const loop = useCallback(() => {
358
367
  rafRef.current = requestAnimationFrame(loop);
@@ -391,7 +400,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
391
400
  // In seamless mode the scroll never actually reaches the bottom; just keep scrolling
392
401
  phaseRef.current = "idle";
393
402
  } else if (isAtBottom(body)) {
394
- if (onAutoScrollEndAdd) {
403
+ if (hasOnAutoScrollEndAdd) {
395
404
  runLoadMore();
396
405
  } else {
397
406
  if (autoScrollLoop) {
@@ -457,7 +466,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
457
466
  isAtBottom,
458
467
  isBodyScrollable,
459
468
  isSeamless,
460
- onAutoScrollEndAdd,
469
+ hasOnAutoScrollEndAdd,
461
470
  ready,
462
471
  ref,
463
472
  runLoadMore,
@@ -506,7 +515,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
506
515
  return;
507
516
  }
508
517
 
509
- if (onAutoScrollEndAdd) {
518
+ if (hasOnAutoScrollEndAdd) {
510
519
  // In load-more mode, if scrolling had stopped, resume auto-scrolling when new data arrives
511
520
  if (phaseRef.current === "stopped") {
512
521
  phaseRef.current = autoScrollMode === "row" ? "wait" : "idle";
@@ -530,7 +539,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
530
539
  interval,
531
540
  isBodyScrollable,
532
541
  isSeamless,
533
- onAutoScrollEndAdd,
542
+ hasOnAutoScrollEndAdd,
534
543
  ]);
535
544
 
536
545
  useEffect(() => {
@@ -0,0 +1,56 @@
1
+ import React, { useState } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render } from "@testing-library/react";
4
+ import { MemoryRouter } from "react-router-dom";
5
+
6
+ import { useOnlyLvOneMenu } from "./useOnlyLvOneMenu";
7
+ import type { MenuType } from "..";
8
+
9
+ const ITEMS = [
10
+ { key: "/orch", label: "编排", children: [{ key: "/orch/a", label: "A" }] },
11
+ ] as unknown as MenuType[];
12
+
13
+ /**
14
+ * 这个 effect 干的是:路由变了就把当前一级菜单的子项交给消费方,并同步展开态。
15
+ * getCurrChildItems 是 effect 体里发出去的通知 —— 它进依赖数组,
16
+ * 配上每次都新建数组的 setOpenkeys([item.key]),就是标准的死循环配方。
17
+ */
18
+ function Host({ onChildren }: { onChildren: (c: MenuType[]) => void }) {
19
+ const [, setOpenkeys] = useState<string[]>([]);
20
+ const [, setMenuKey] = useState("");
21
+ const [children, setChildren] = useState<MenuType[]>([]);
22
+
23
+ useOnlyLvOneMenu({
24
+ items: ITEMS,
25
+ onlyLvOneMenu: true,
26
+ // 内联箭头 + 每次都是新数组:最坏情况的消费方写法
27
+ getCurrChildItems: (c) => {
28
+ onChildren(c);
29
+ setChildren([...c]);
30
+ },
31
+ setOpenkeys,
32
+ setMenuKey,
33
+ });
34
+
35
+ return <div data-testid="count">{children.length}</div>;
36
+ }
37
+
38
+ describe("useOnlyLvOneMenu", () => {
39
+ it("内联 getCurrChildItems 回写 state 不会死循环", () => {
40
+ const spy = vi.fn();
41
+
42
+ const { getByTestId } = render(
43
+ <MemoryRouter
44
+ initialEntries={["/orch/a"]}
45
+ future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
46
+ >
47
+ <Host onChildren={spy} />
48
+ </MemoryRouter>,
49
+ );
50
+
51
+ expect(getByTestId("count").textContent).toBe("1");
52
+ // 进了循环这里会是几十上百次,直到 React 抛 Maximum update depth exceeded
53
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
54
+ expect(spy).toHaveBeenCalledWith(ITEMS[0].children);
55
+ });
56
+ });
@@ -1,4 +1,5 @@
1
1
  import { useEffect } from "react";
2
+ import { useLatestRef } from "../../../hooks/useLatestRef";
2
3
  import { useLocation } from "react-router";
3
4
  import { MenuType } from "..";
4
5
 
@@ -21,6 +22,11 @@ export const useOnlyLvOneMenu = ({
21
22
  setMenuKey,
22
23
  }: UseOnlyLvOneMenuOptions) => {
23
24
  const location = useLocation();
25
+ // getCurrChildItems 是 effect 体里发出去的通知,本身进依赖数组是这一类死循环的标准配方:
26
+ // 消费方传内联箭头 → 每次渲染新引用 → effect 重跑 → setOpenkeys([item.key]) 每次都是
27
+ // 新数组必定触发重渲染 → 又是新引用 …… 直到 React 抛 Maximum update depth exceeded。
28
+ // 通知取 ref 里的最新引用,依赖数组只留真正的输入。
29
+ const getCurrChildItemsRef = useLatestRef(getCurrChildItems);
24
30
 
25
31
  useEffect(() => {
26
32
  if (onlyLvOneMenu) {
@@ -28,14 +34,14 @@ export const useOnlyLvOneMenu = ({
28
34
  (i) => i.key === "/" + location.pathname.split("/").filter(Boolean)[0]
29
35
  );
30
36
  if (item) {
31
- getCurrChildItems?.(item.children || []);
37
+ getCurrChildItemsRef.current?.(item.children || []);
32
38
  setOpenkeys([item.key]);
33
39
  setMenuKey(item.key);
34
40
  }
35
41
  }
36
42
  }, [
37
43
  items,
38
- getCurrChildItems,
44
+ getCurrChildItemsRef,
39
45
  location,
40
46
  onlyLvOneMenu,
41
47
  setOpenkeys,