@hsu-react/ui 2.5.3 → 2.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/es/components/ChainGraph/_hooks/useChainGraphData.js +29 -8
  2. package/es/components/ChainGraph/_hooks/useChainGraphLayout.js +14 -3
  3. package/es/components/Chart/Bar/index.js +49 -19
  4. package/es/components/Chart/Bubble/index.js +21 -6
  5. package/es/components/Chart/Common/index.js +7 -2
  6. package/es/components/Chart/Heatmap/index.js +21 -6
  7. package/es/components/Chart/Line/index.js +49 -19
  8. package/es/components/Chart/Pie/Pie3D/index.js +22 -6
  9. package/es/components/Chart/Pie/index.js +21 -6
  10. package/es/components/Chart/Sankey/index.js +11 -2
  11. package/es/components/Chart/Tree/index.js +11 -2
  12. package/es/components/Chat/ChatInput/index.js +7 -2
  13. package/es/components/Icon/index.js +8 -2
  14. package/es/components/Input/Number/index.js +28 -21
  15. package/es/components/Input/Password/index.js +44 -36
  16. package/es/components/Input/Search/index.js +44 -36
  17. package/es/components/Input/TextArea/index.js +64 -42
  18. package/es/components/Input/index.js +40 -34
  19. package/es/components/Select/_hooks/useSelectComposition.js +6 -2
  20. package/es/components/Select/_hooks/useSelectInputOptions.js +8 -2
  21. package/es/components/Slider/index.js +24 -15
  22. package/es/components/Table/_hooks/useAutoScrolling.js +16 -7
  23. package/es/components/Upload/_hooks/useUploadFileList.js +8 -2
  24. package/es/components/Upload/_hooks/useUploadOperations.js +8 -2
  25. package/es/hooks/useLatestRef.d.ts +24 -0
  26. package/es/hooks/useLatestRef.js +29 -0
  27. package/es/layout/Menu/_hooks/useOnlyLvOneMenu.js +9 -2
  28. package/lib/components/ChainGraph/_hooks/useChainGraphData.js +24 -7
  29. package/lib/components/ChainGraph/_hooks/useChainGraphLayout.js +12 -2
  30. package/lib/components/Chart/Bar/index.js +45 -18
  31. package/lib/components/Chart/Bubble/index.js +19 -6
  32. package/lib/components/Chart/Common/index.js +6 -2
  33. package/lib/components/Chart/Heatmap/index.js +19 -6
  34. package/lib/components/Chart/Line/index.js +45 -18
  35. package/lib/components/Chart/Pie/Pie3D/index.js +20 -6
  36. package/lib/components/Chart/Pie/index.js +19 -6
  37. package/lib/components/Chart/Sankey/index.js +10 -2
  38. package/lib/components/Chart/Tree/index.js +10 -2
  39. package/lib/components/Chat/ChatInput/index.js +6 -2
  40. package/lib/components/Icon/index.js +7 -2
  41. package/lib/components/Input/Number/index.js +27 -18
  42. package/lib/components/Input/Password/index.js +40 -26
  43. package/lib/components/Input/Search/index.js +42 -26
  44. package/lib/components/Input/TextArea/index.js +54 -26
  45. package/lib/components/Input/index.js +37 -24
  46. package/lib/components/Select/_hooks/useSelectComposition.js +6 -2
  47. package/lib/components/Select/_hooks/useSelectInputOptions.js +7 -2
  48. package/lib/components/Slider/index.js +23 -11
  49. package/lib/components/Table/_hooks/useAutoScrolling.js +16 -7
  50. package/lib/components/Upload/_hooks/useUploadFileList.js +7 -2
  51. package/lib/components/Upload/_hooks/useUploadOperations.js +7 -2
  52. package/lib/hooks/useLatestRef.d.ts +24 -0
  53. package/lib/hooks/useLatestRef.js +35 -0
  54. package/lib/layout/Menu/_hooks/useOnlyLvOneMenu.js +8 -2
  55. package/package.json +9 -3
  56. package/src/__tests__/callbackDepsGuard.test.ts +119 -0
  57. package/src/__tests__/setup.ts +26 -0
  58. package/src/components/ChainGraph/_hooks/callbacks.test.tsx +155 -0
  59. package/src/components/ChainGraph/_hooks/useChainGraphData.ts +38 -10
  60. package/src/components/ChainGraph/_hooks/useChainGraphLayout.ts +16 -3
  61. package/src/components/Chart/Bar/index.tsx +59 -21
  62. package/src/components/Chart/Bubble/index.tsx +22 -7
  63. package/src/components/Chart/Common/index.tsx +7 -2
  64. package/src/components/Chart/Heatmap/index.tsx +25 -6
  65. package/src/components/Chart/Line/index.tsx +59 -21
  66. package/src/components/Chart/Pie/Pie3D/index.tsx +22 -7
  67. package/src/components/Chart/Pie/index.tsx +22 -7
  68. package/src/components/Chart/Sankey/index.tsx +10 -4
  69. package/src/components/Chart/Tree/index.tsx +10 -4
  70. package/src/components/Chart/callbacks.test.tsx +315 -0
  71. package/src/components/Chat/ChatInput/index.tsx +7 -2
  72. package/src/components/Icon/index.tsx +7 -2
  73. package/src/components/Input/Number/index.tsx +25 -23
  74. package/src/components/Input/Password/index.tsx +37 -26
  75. package/src/components/Input/Search/index.tsx +39 -26
  76. package/src/components/Input/TextArea/index.test.tsx +208 -0
  77. package/src/components/Input/TextArea/index.tsx +56 -30
  78. package/src/components/Input/index.test.tsx +189 -0
  79. package/src/components/Input/index.tsx +36 -29
  80. package/src/components/Select/_hooks/useSelectComposition.test.ts +47 -0
  81. package/src/components/Select/_hooks/useSelectComposition.ts +6 -2
  82. package/src/components/Select/_hooks/useSelectInputOptions.ts +6 -9
  83. package/src/components/Slider/index.tsx +22 -12
  84. package/src/components/Table/_hooks/useAutoScrolling.test.tsx +95 -0
  85. package/src/components/Table/_hooks/useAutoScrolling.ts +16 -7
  86. package/src/components/Upload/_hooks/useUploadFileList.ts +6 -2
  87. package/src/components/Upload/_hooks/useUploadOperations.ts +7 -2
  88. package/src/hooks/useLatestRef.ts +31 -0
  89. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.test.tsx +56 -0
  90. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.ts +8 -2
@@ -0,0 +1,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
  }
@@ -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
  );
@@ -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(() => {
@@ -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;
@@ -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,