@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
@@ -87,6 +87,12 @@ const ChartPie3D = props => {
87
87
  });
88
88
  const onChartRef = (0, _useLatestRef.useLatestRef)(onChart);
89
89
 
90
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑会重建
91
+ // 3D 饼的 option 并重播动画。注册与否看布尔量(保证「从无到有传入」仍会注册),
92
+ // 实际调用取 ref 里的最新引用。
93
+ const onClickRef = (0, _useLatestRef.useLatestRef)(onClick);
94
+ const hasOnClick = !!onClick;
95
+
90
96
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
91
97
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
92
98
  (0, _react.useEffect)(() => {
@@ -106,20 +112,23 @@ const ChartPie3D = props => {
106
112
  }
107
113
  chart.on("mouseover", handleMouseOver);
108
114
  chart.on("globalout", handleGlobalOut);
109
- if (onClick) {
110
- chart.on("click", onClick);
115
+ const handleClick = event => {
116
+ onClickRef.current?.(event);
117
+ };
118
+ if (hasOnClick) {
119
+ chart.on("click", handleClick);
111
120
  }
112
121
  return () => {
113
122
  window.removeEventListener("resize", handleResize);
114
123
  if (chart) {
115
124
  chart.off("mouseover", handleMouseOver);
116
125
  chart.off("globalout", handleGlobalOut);
117
- if (onClick) {
118
- chart.off("click", onClick);
126
+ if (hasOnClick) {
127
+ chart.off("click", handleClick);
119
128
  }
120
129
  }
121
130
  };
122
- }, [chartOption, handleResize, handleMouseOver, handleGlobalOut, onChartRef, onClick, containerReady]);
131
+ }, [chartOption, handleResize, handleMouseOver, handleGlobalOut, onChartRef, onClickRef, hasOnClick, containerReady]);
123
132
  (0, _react.useEffect)(() => {
124
133
  return () => {
125
134
  if (resizeObserverRef.current) {
@@ -203,6 +203,12 @@ const ChartPie = props => {
203
203
  }, []);
204
204
  const onChartRef = (0, _useLatestRef.useLatestRef)(onChart);
205
205
 
206
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
207
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
208
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
209
+ const onClickRef = (0, _useLatestRef.useLatestRef)(onClick);
210
+ const hasOnClick = !!onClick;
211
+
206
212
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
207
213
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
208
214
  // Initialize the chart
@@ -231,8 +237,11 @@ const ChartPie = props => {
231
237
  }
232
238
 
233
239
  // Add click event
234
- if (onClick) {
235
- chartInstanceRef.current?.on("click", onClick);
240
+ const handleClick = event => {
241
+ onClickRef.current?.(event);
242
+ };
243
+ if (hasOnClick) {
244
+ chartInstanceRef.current?.on("click", handleClick);
236
245
  }
237
246
 
238
247
  // Legend auto-scroll
@@ -253,8 +262,8 @@ const ChartPie = props => {
253
262
  // Cleanup function
254
263
  return () => {
255
264
  window.removeEventListener("resize", handleResize);
256
- if (onClick) {
257
- chartInstanceRef.current?.off("click", onClick);
265
+ if (hasOnClick) {
266
+ chartInstanceRef.current?.off("click", handleClick);
258
267
  }
259
268
 
260
269
  // Clean up legend scrolling
@@ -263,7 +272,7 @@ const ChartPie = props => {
263
272
  legendScrollRef.current = null;
264
273
  }
265
274
  };
266
- }, [chartOption, handleResize, onChartRef, onClick, enableLegendAutoScroll, seriesData, legendVisibleCount, legendScrollInterval, containerReady]);
275
+ }, [chartOption, handleResize, onChartRef, onClickRef, hasOnClick, enableLegendAutoScroll, seriesData, legendVisibleCount, legendScrollInterval, containerReady]);
267
276
 
268
277
  // Clean up resources when the component unmounts
269
278
  (0, _react.useEffect)(() => {
@@ -8,6 +8,7 @@ var _react = _interopRequireWildcard(require("react"));
8
8
  var _useContainerReady = _interopRequireDefault(require("../_hooks/useContainerReady"));
9
9
  var _indexModule = _interopRequireDefault(require("../index.module.scss"));
10
10
  var echarts = _interopRequireWildcard(require("echarts"));
11
+ var _useLatestRef = _interopRequireDefault(require("../../../hooks/useLatestRef"));
11
12
  var _jsxRuntime = require("react/jsx-runtime");
12
13
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
13
14
  function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
@@ -104,6 +105,13 @@ const Sankey = props => {
104
105
  return option;
105
106
  }, [seriesData, seriesLinks, series]);
106
107
 
108
+ // getImage 只是「出图后把 dataURL 交出去」的出口,既不决定图怎么画,
109
+ // 也不该决定要不要重画。它进依赖数组会让 effect 跟着重跑 → setOption(notMerge)
110
+ // → 图重绘 → 再触发一次 finished → 再回调;消费方把图存进 state 就是死循环。
111
+ // 而且 finished 监听只在首次 init 时注册,闭包里锁死的是首帧的 getImage,
112
+ // 后续换了引用永远调不到 —— 依赖数组写了它也没用,是条死依赖。走 ref 两个问题一起解决。
113
+ const getImageRef = (0, _useLatestRef.default)(getImage);
114
+
107
115
  // Callback handling chart resize
108
116
  const handleResize = (0, _react.useCallback)(() => {
109
117
  // A keep-alive tab losing focus fires the observer with a 0×0 box; resizing to that throws
@@ -125,7 +133,7 @@ const Sankey = props => {
125
133
 
126
134
  // Attach the finished event listener on first initialization
127
135
  chart.on("finished", () => {
128
- getImage?.(chart.getDataURL({
136
+ getImageRef.current?.(chart.getDataURL({
129
137
  type: "png",
130
138
  pixelRatio: 1,
131
139
  backgroundColor: "#fff"
@@ -149,7 +157,7 @@ const Sankey = props => {
149
157
  return () => {
150
158
  window.removeEventListener("resize", handleResize);
151
159
  };
152
- }, [chartOption, handleResize, getImage, containerReady]);
160
+ }, [chartOption, handleResize, getImageRef, containerReady]);
153
161
 
154
162
  // Clean up resources on unmount
155
163
  (0, _react.useEffect)(() => {
@@ -8,6 +8,7 @@ var _react = _interopRequireWildcard(require("react"));
8
8
  var _useContainerReady = _interopRequireDefault(require("../_hooks/useContainerReady"));
9
9
  var _indexModule = _interopRequireDefault(require("../index.module.scss"));
10
10
  var echarts = _interopRequireWildcard(require("echarts"));
11
+ var _useLatestRef = _interopRequireDefault(require("../../../hooks/useLatestRef"));
11
12
  var _hsuUtils = require("hsu-utils");
12
13
  var _tree = require("../_utils/tree");
13
14
  var _jsxRuntime = require("react/jsx-runtime");
@@ -44,6 +45,13 @@ const Tree = props => {
44
45
  return option;
45
46
  }, [seriesData, series]);
46
47
 
48
+ // getImage 只是「出图后把 dataURL 交出去」的出口,既不决定图怎么画,
49
+ // 也不该决定要不要重画。它进依赖数组会让 effect 跟着重跑 → setOption(notMerge)
50
+ // → 图重绘 → 再触发一次 finished → 再回调;消费方把图存进 state 就是死循环。
51
+ // 而且 finished 监听只在首次 init 时注册,闭包里锁死的是首帧的 getImage,
52
+ // 后续换了引用永远调不到 —— 依赖数组写了它也没用,是条死依赖。走 ref 两个问题一起解决。
53
+ const getImageRef = (0, _useLatestRef.default)(getImage);
54
+
47
55
  // Callback handling chart resize
48
56
  const handleResize = (0, _react.useCallback)(() => {
49
57
  // A keep-alive tab losing focus fires the observer with a 0×0 box; resizing to that throws
@@ -65,7 +73,7 @@ const Tree = props => {
65
73
 
66
74
  // Attach the finished event listener on first initialization
67
75
  chart.on("finished", () => {
68
- getImage?.(chart.getDataURL({
76
+ getImageRef.current?.(chart.getDataURL({
69
77
  type: "png",
70
78
  pixelRatio: 1,
71
79
  backgroundColor: "#fff"
@@ -89,7 +97,7 @@ const Tree = props => {
89
97
  return () => {
90
98
  window.removeEventListener("resize", handleResize);
91
99
  };
92
- }, [chartOption, handleResize, getImage, containerReady]);
100
+ }, [chartOption, handleResize, getImageRef, containerReady]);
93
101
 
94
102
  // Clean up resources on unmount
95
103
  (0, _react.useEffect)(() => {
@@ -12,6 +12,7 @@ function useSelectComposition({
12
12
  onSearch
13
13
  }) {
14
14
  const [isComposing, setComposing] = (0, _react.useState)(false);
15
+ const hasOnSearch = !!onSearch;
15
16
  (0, _react.useEffect)(() => {
16
17
  const compositionend = () => {
17
18
  setComposing(false);
@@ -19,7 +20,7 @@ function useSelectComposition({
19
20
  const compositionstart = () => {
20
21
  setComposing(true);
21
22
  };
22
- if (onSearch) {
23
+ if (hasOnSearch) {
23
24
  window.addEventListener("compositionstart", compositionstart);
24
25
  window.addEventListener("compositionend", compositionend);
25
26
  }
@@ -27,7 +28,10 @@ function useSelectComposition({
27
28
  window.removeEventListener("compositionstart", compositionstart);
28
29
  window.removeEventListener("compositionend", compositionend);
29
30
  };
30
- }, [onSearch]);
31
+ // onSearch 在这里只被当作「要不要监听输入法事件」的真值判断,effect 体从不调它。
32
+ // 直接进依赖数组的话,消费方传内联箭头就会每渲染一次拆一次、装一次 window 监听;
33
+ // 只取「有没有传」这个布尔量,从无到有时照样会重新注册。
34
+ }, [hasOnSearch]);
31
35
  return {
32
36
  isComposing
33
37
  };
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.default = void 0;
7
7
  var _ahooks = require("ahooks");
8
8
  var _react = require("react");
9
+ var _useLatestRef = require("../../../hooks/useLatestRef");
9
10
  const DEFAULT_INTERVAL = 2000;
10
11
  const DEFAULT_SPEED = 25;
11
12
  const SMOOTH_SCROLL_TICK = 10;
@@ -24,6 +25,13 @@ const useAutoScrolling = props => {
24
25
  autoScrollLoopMode = "reset",
25
26
  autoScrollingOffset = 0
26
27
  } = props;
28
+
29
+ // onAutoScrollEndAdd 在这里身兼两职:既是「有没有加载更多」的开关,又是真正去加载的动作。
30
+ // 整个 rAF 循环(loop → 依赖它 → 启停 effect 依赖 loop)都挂在它的引用上,
31
+ // 消费方传内联箭头就会每渲染一次重启一次滚动循环 —— 表格滚到一半被打回起点。
32
+ // 拆成两半:开关看布尔量(从无到有传入时仍会正确接上加载更多),动作取 ref 里的最新引用。
33
+ const onAutoScrollEndAddRef = (0, _useLatestRef.useLatestRef)(onAutoScrollEndAdd);
34
+ const hasOnAutoScrollEndAdd = !!onAutoScrollEndAdd;
27
35
  const validSpeed = autoScrollingSpeed > 0 ? autoScrollingSpeed : DEFAULT_SPEED;
28
36
  const smoothStep = validSpeed * SMOOTH_SCROLL_TICK / 1000;
29
37
  const [ready, setReady] = (0, _react.useState)(false);
@@ -237,11 +245,12 @@ const useAutoScrolling = props => {
237
245
  }, SMOOTH_SCROLL_TICK);
238
246
  }, [bindHover, checkSeamlessReset, clearSmoothTimer, getBody, interval, isAtBottom, isBodyScrollable, isSeamless, smoothStep]);
239
247
  const runLoadMore = (0, _react.useCallback)(() => {
240
- if (!onAutoScrollEndAdd || pendingLoadMoreRef.current) return;
248
+ const loadMore = onAutoScrollEndAddRef.current;
249
+ if (!loadMore || pendingLoadMoreRef.current) return;
241
250
  pendingLoadMoreRef.current = true;
242
251
  phaseRef.current = "loading";
243
252
  const token = ++loadTokenRef.current;
244
- onAutoScrollEndAdd().then(hasMore => {
253
+ loadMore().then(hasMore => {
245
254
  if (token !== loadTokenRef.current) return;
246
255
  const currentBody = getBody();
247
256
  if (hasMore) {
@@ -261,7 +270,7 @@ const useAutoScrolling = props => {
261
270
  if (token !== loadTokenRef.current) return;
262
271
  pendingLoadMoreRef.current = false;
263
272
  });
264
- }, [autoScrollLoop, getBody, onAutoScrollEndAdd]);
273
+ }, [autoScrollLoop, getBody, onAutoScrollEndAddRef]);
265
274
  const loop = (0, _react.useCallback)(() => {
266
275
  rafRef.current = requestAnimationFrame(loop);
267
276
  if (!autoScrolling || !ready || !dataSource?.length || !ref?.current) {
@@ -293,7 +302,7 @@ const useAutoScrolling = props => {
293
302
  // In seamless mode the scroll never actually reaches the bottom; just keep scrolling
294
303
  phaseRef.current = "idle";
295
304
  } else if (isAtBottom(body)) {
296
- if (onAutoScrollEndAdd) {
305
+ if (hasOnAutoScrollEndAdd) {
297
306
  runLoadMore();
298
307
  } else {
299
308
  if (autoScrollLoop) {
@@ -339,7 +348,7 @@ const useAutoScrolling = props => {
339
348
  return;
340
349
  }
341
350
  startSmoothStep(body, targetTop);
342
- }, [autoScrollMode, autoScrollLoop, autoScrolling, bindHover, checkSeamlessReset, dataSource, getBody, getRowHeight, interval, isAtBottom, isBodyScrollable, isSeamless, onAutoScrollEndAdd, ready, ref, runLoadMore, setupSeamlessClones, startSmoothStep]);
351
+ }, [autoScrollMode, autoScrollLoop, autoScrolling, bindHover, checkSeamlessReset, dataSource, getBody, getRowHeight, interval, isAtBottom, isBodyScrollable, isSeamless, hasOnAutoScrollEndAdd, ready, ref, runLoadMore, setupSeamlessClones, startSmoothStep]);
343
352
  (0, _react.useEffect)(() => {
344
353
  if (!autoScrolling || !ready || !dataSource?.length || !ref?.current) {
345
354
  cleanup();
@@ -364,7 +373,7 @@ const useAutoScrolling = props => {
364
373
  cleanupClones();
365
374
  return;
366
375
  }
367
- if (onAutoScrollEndAdd) {
376
+ if (hasOnAutoScrollEndAdd) {
368
377
  // In load-more mode, if scrolling had stopped, resume auto-scrolling when new data arrives
369
378
  if (phaseRef.current === "stopped") {
370
379
  phaseRef.current = autoScrollMode === "row" ? "wait" : "idle";
@@ -377,7 +386,7 @@ const useAutoScrolling = props => {
377
386
  phaseRef.current = autoScrollMode === "row" ? "wait" : "idle";
378
387
  waitUntilRef.current = autoScrollMode === "row" ? performance.now() + interval : 0;
379
388
  }
380
- }, [autoScrollMode, cleanupClones, dataSource, getBody, interval, isBodyScrollable, isSeamless, onAutoScrollEndAdd]);
389
+ }, [autoScrollMode, cleanupClones, dataSource, getBody, interval, isBodyScrollable, isSeamless, hasOnAutoScrollEndAdd]);
381
390
  (0, _react.useEffect)(() => {
382
391
  if (!autoScrolling || !ref?.current) return;
383
392
  let resizeRaf = null;
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useOnlyLvOneMenu = void 0;
7
7
  var _react = require("react");
8
+ var _useLatestRef = require("../../../hooks/useLatestRef");
8
9
  var _reactRouter = require("react-router");
9
10
  /**
10
11
  * Handle the top-level-only menu logic
@@ -17,15 +18,20 @@ const useOnlyLvOneMenu = ({
17
18
  setMenuKey
18
19
  }) => {
19
20
  const location = (0, _reactRouter.useLocation)();
21
+ // getCurrChildItems 是 effect 体里发出去的通知,本身进依赖数组是这一类死循环的标准配方:
22
+ // 消费方传内联箭头 → 每次渲染新引用 → effect 重跑 → setOpenkeys([item.key]) 每次都是
23
+ // 新数组必定触发重渲染 → 又是新引用 …… 直到 React 抛 Maximum update depth exceeded。
24
+ // 通知取 ref 里的最新引用,依赖数组只留真正的输入。
25
+ const getCurrChildItemsRef = (0, _useLatestRef.useLatestRef)(getCurrChildItems);
20
26
  (0, _react.useEffect)(() => {
21
27
  if (onlyLvOneMenu) {
22
28
  const item = items.find(i => i.key === "/" + location.pathname.split("/").filter(Boolean)[0]);
23
29
  if (item) {
24
- getCurrChildItems?.(item.children || []);
30
+ getCurrChildItemsRef.current?.(item.children || []);
25
31
  setOpenkeys([item.key]);
26
32
  setMenuKey(item.key);
27
33
  }
28
34
  }
29
- }, [items, getCurrChildItems, location, onlyLvOneMenu, setOpenkeys, setMenuKey]);
35
+ }, [items, getCurrChildItemsRef, location, onlyLvOneMenu, setOpenkeys, setMenuKey]);
30
36
  };
31
37
  exports.useOnlyLvOneMenu = useOnlyLvOneMenu;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hsu-react/ui",
3
- "version": "2.5.4",
3
+ "version": "2.5.5",
4
4
  "description": "一套基于 React + Ant Design 的中后台业务组件库",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ /**
6
+ * 防回归守卫:**回调 prop 不许进 effect 依赖数组**。
7
+ *
8
+ * 这条缺陷没有任何信号——不报错、不变慢,只是消费方一传内联箭头
9
+ * (`onXxx={(v) => setV(v)}`,React 里最常见的写法)就重复订阅 / 重复通知,
10
+ * 严重的直接死循环到 `Maximum update depth exceeded`。
11
+ * 靠人工 review 拦不住,所以扫源码。
12
+ *
13
+ * 正确写法见 `src/hooks/useLatestRef.ts`:
14
+ * - 调用取 ref 里的最新引用;
15
+ * - 「有没有传回调」若决定要不要注册监听,就单独拆一个布尔量进依赖数组,
16
+ * 这样「从无到有传入回调」仍会正确注册。
17
+ *
18
+ * 确实无害的写法登记在 ALLOWED 里,每条都要写清为什么无害。
19
+ */
20
+
21
+ const SRC = path.resolve(__dirname, "..");
22
+
23
+ /** 回调型标识符:`onXxx` 形态,外加几个明确的出口型回调名 */
24
+ const EXTRA_CALLBACK_NAMES = new Set(["getImage", "labelRender"]);
25
+ const isCallbackDep = (name: string) =>
26
+ (/^on[A-Z]/.test(name) && !name.endsWith("Ref")) ||
27
+ EXTRA_CALLBACK_NAMES.has(name);
28
+
29
+ /** `文件相对路径::依赖名` → 无害的理由 */
30
+ const ALLOWED: Record<string, string> = {
31
+ "components/Chat/ChatHistory/index.tsx::onScrollEnd":
32
+ "只在 DOM scroll 监听里调,effect 体不调它;重订阅是同一节点上摘一个装一个,无副作用",
33
+ "components/DatePicker/index.tsx::onChange":
34
+ "纯 ref 同步 effect:effect 体只写 ref,不调回调、不订阅任何东西",
35
+ "components/DatePicker/RangePicker/index.tsx::onChange":
36
+ "同上,纯 ref 同步 effect",
37
+ "components/Tree/index.tsx::onSelectPath": "同上,纯 ref 同步 effect",
38
+ };
39
+
40
+ function walk(dir: string, out: string[] = []) {
41
+ for (const name of fs.readdirSync(dir)) {
42
+ const full = path.join(dir, name);
43
+ if (fs.statSync(full).isDirectory()) walk(full, out);
44
+ else if (/\.(tsx?|jsx?)$/.test(name) && !/\.test\./.test(name))
45
+ out.push(full);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** 找出每个 useEffect / useLayoutEffect 的依赖数组,返回 [行号, 依赖名] */
51
+ function effectDeps(src: string): Array<[number, string]> {
52
+ const found: Array<[number, string]> = [];
53
+ const re = /use(?:Layout)?Effect\(/g;
54
+ let m: RegExpExecArray | null;
55
+
56
+ while ((m = re.exec(src))) {
57
+ let i = m.index + m[0].length;
58
+ let depth = 1;
59
+ let quote: string | null = null;
60
+ let prev = "";
61
+
62
+ while (i < src.length && depth > 0) {
63
+ const c = src[i];
64
+ if (quote) {
65
+ if (c === quote && prev !== "\\") quote = null;
66
+ } else if (c === '"' || c === "'" || c === "`") quote = c;
67
+ else if (c === "(" || c === "[" || c === "{") depth++;
68
+ else if (c === ")" || c === "]" || c === "}") depth--;
69
+ prev = c;
70
+ i++;
71
+ }
72
+
73
+ const body = src.slice(m.index, i).replace(/\)\s*$/, "");
74
+ const deps = body.match(/,\s*\[([\s\S]*)\]\s*$/);
75
+ if (!deps) continue;
76
+
77
+ const line = src.slice(0, m.index).split("\n").length;
78
+ for (const raw of deps[1].split(",")) {
79
+ const name = raw
80
+ .replace(/\/\/.*$/gm, "")
81
+ .trim()
82
+ .split(/[.?[]/)[0]
83
+ .trim();
84
+ if (name) found.push([line, name]);
85
+ }
86
+ }
87
+
88
+ return found;
89
+ }
90
+
91
+ describe("回调 prop 不进 effect 依赖数组", () => {
92
+ it("全库无未登记的命中", () => {
93
+ const offenders: string[] = [];
94
+
95
+ for (const file of walk(SRC)) {
96
+ const rel = path.relative(SRC, file).split(path.sep).join("/");
97
+ const src = fs.readFileSync(file, "utf8");
98
+
99
+ for (const [line, dep] of effectDeps(src)) {
100
+ if (!isCallbackDep(dep)) continue;
101
+ if (ALLOWED[`${rel}::${dep}`]) continue;
102
+ offenders.push(`${rel}:${line} ${dep}`);
103
+ }
104
+ }
105
+
106
+ expect(offenders).toEqual([]);
107
+ });
108
+
109
+ it("守卫本身能抓到问题(自测)", () => {
110
+ const bad = `
111
+ useEffect(() => {
112
+ onDone?.();
113
+ }, [value, onDone]);
114
+ `;
115
+ expect(effectDeps(bad).map(([, d]) => d)).toContain("onDone");
116
+ expect(isCallbackDep("onDone")).toBe(true);
117
+ expect(isCallbackDep("onDoneRef")).toBe(false);
118
+ });
119
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { act, renderHook } from "@testing-library/react";
3
+
4
+ vi.mock("../ChainGraphServices", () => ({ default: class {} }));
5
+
6
+ import { useChainGraphLayout } from "./useChainGraphLayout";
7
+ import { useChainGraphData } from "./useChainGraphData";
8
+ import type ChainGraphServices from "../ChainGraphServices";
9
+ import type { TreeGraphData } from "..";
10
+
11
+ /**
12
+ * ChainGraph 把回调交给图实例,由图实例在后续事件里回调。
13
+ * 这类「出口型」回调进依赖数组,会让消费方传内联箭头时反复触发重排 / 重新 setData。
14
+ */
15
+
16
+ const DATA = { id: "root", label: "root" } as unknown as TreeGraphData;
17
+
18
+ describe("useChainGraphLayout", () => {
19
+ const makeGraph = () =>
20
+ ({ changeLayout: vi.fn() }) as unknown as ChainGraphServices & {
21
+ changeLayout: ReturnType<typeof vi.fn>;
22
+ };
23
+
24
+ it("getImage 换引用不会重新 changeLayout(整张图重排)", () => {
25
+ const graph = makeGraph();
26
+ const { rerender } = renderHook(
27
+ ({ getImage }: { getImage?: (img: string) => void }) =>
28
+ useChainGraphLayout({ graph, octopus: true, rootLevel: 0, getImage }),
29
+ { initialProps: { getImage: vi.fn() } },
30
+ );
31
+
32
+ expect(graph.changeLayout).toHaveBeenCalledTimes(1);
33
+
34
+ rerender({ getImage: vi.fn() });
35
+ rerender({ getImage: vi.fn() });
36
+
37
+ expect(graph.changeLayout).toHaveBeenCalledTimes(1);
38
+ });
39
+
40
+ it("出图时调到的是最新的 getImage,包括从无到有传入的情况", () => {
41
+ const graph = makeGraph();
42
+ const { rerender } = renderHook(
43
+ ({ getImage }: { getImage?: (img: string) => void }) =>
44
+ useChainGraphLayout({ graph, octopus: true, rootLevel: 0, getImage }),
45
+ { initialProps: {} as { getImage?: (img: string) => void } },
46
+ );
47
+
48
+ const forwarded = graph.changeLayout.mock.calls[0][2] as (
49
+ img: string,
50
+ ) => void;
51
+ // 没传回调时转发函数照样存在,调用不报错
52
+ expect(() => forwarded("img-0")).not.toThrow();
53
+
54
+ const later = vi.fn();
55
+ rerender({ getImage: later });
56
+ // 图实例里存的还是同一个转发函数,但它现在会转给新传入的回调
57
+ forwarded("img-1");
58
+ expect(later).toHaveBeenCalledWith("img-1");
59
+ });
60
+ });
61
+
62
+ describe("useChainGraphData", () => {
63
+ const makeGraph = () =>
64
+ ({ setData: vi.fn() }) as unknown as ChainGraphServices & {
65
+ setData: ReturnType<typeof vi.fn>;
66
+ };
67
+
68
+ type Props = {
69
+ getImage?: (img: string) => void;
70
+ labelRender?: (label: TreeGraphData) => string;
71
+ onLayoutingChange?: (v: boolean) => void;
72
+ };
73
+
74
+ it("三个回调换引用都不会重新 setData", () => {
75
+ const graph = makeGraph();
76
+ const { rerender } = renderHook(
77
+ (props: Props) => useChainGraphData({ graph, data: DATA, ...props }),
78
+ {
79
+ initialProps: {
80
+ getImage: vi.fn(),
81
+ labelRender: vi.fn(() => "a"),
82
+ onLayoutingChange: vi.fn(),
83
+ } as Props,
84
+ },
85
+ );
86
+
87
+ expect(graph.setData).toHaveBeenCalledTimes(1);
88
+
89
+ rerender({
90
+ getImage: vi.fn(),
91
+ labelRender: vi.fn(() => "b"),
92
+ onLayoutingChange: vi.fn(),
93
+ });
94
+
95
+ expect(graph.setData).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it("图实例回调时取到的是最新引用,不是首次 setData 时的那个", () => {
99
+ const graph = makeGraph();
100
+ const firstLabel = vi.fn(() => "first");
101
+ const { rerender } = renderHook(
102
+ (props: Props) => useChainGraphData({ graph, data: DATA, ...props }),
103
+ {
104
+ initialProps: {
105
+ getImage: vi.fn(),
106
+ labelRender: firstLabel,
107
+ onLayoutingChange: vi.fn(),
108
+ } as Props,
109
+ },
110
+ );
111
+
112
+ const pushed = graph.setData.mock.calls[0][0] as {
113
+ getImage: (img: string) => void;
114
+ labelRender?: (label: TreeGraphData) => string;
115
+ isLayouting: (v: boolean) => void;
116
+ };
117
+
118
+ const nextImage = vi.fn();
119
+ const nextLabel = vi.fn(() => "next");
120
+ const nextLayouting = vi.fn();
121
+ rerender({
122
+ getImage: nextImage,
123
+ labelRender: nextLabel,
124
+ onLayoutingChange: nextLayouting,
125
+ });
126
+
127
+ pushed.getImage("img");
128
+ expect(nextImage).toHaveBeenCalledWith("img");
129
+
130
+ expect(pushed.labelRender!(DATA)).toBe("next");
131
+ expect(firstLabel).not.toHaveBeenCalled();
132
+
133
+ act(() => pushed.isLayouting(false));
134
+ expect(nextLayouting).toHaveBeenCalledWith(false);
135
+ });
136
+
137
+ it("labelRender 的「传了才改写 label」语义保留:没传就是 undefined,从无到有会带上", () => {
138
+ const graph = makeGraph();
139
+ const { rerender } = renderHook(
140
+ ({ data, labelRender }: { data: TreeGraphData; labelRender?: Props["labelRender"] }) =>
141
+ useChainGraphData({ graph, data, labelRender }),
142
+ { initialProps: { data: DATA } as { data: TreeGraphData; labelRender?: Props["labelRender"] } },
143
+ );
144
+
145
+ expect(graph.setData.mock.calls[0][0].labelRender).toBeUndefined();
146
+
147
+ const label = vi.fn(() => "x");
148
+ const nextData = { id: "root2", label: "root2" } as unknown as TreeGraphData;
149
+ rerender({ data: nextData, labelRender: label });
150
+
151
+ expect(graph.setData).toHaveBeenCalledTimes(2);
152
+ expect(typeof graph.setData.mock.calls[1][0].labelRender).toBe("function");
153
+ expect(graph.setData.mock.calls[1][0].labelRender(DATA)).toBe("x");
154
+ });
155
+ });
@@ -1,7 +1,8 @@
1
- import { useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useState } from "react";
2
2
  import { Equal } from "hsu-utils";
3
3
  import ChainGraphServices from "../ChainGraphServices";
4
4
  import { TreeGraphData } from "..";
5
+ import { useLatestRef } from "../../../hooks/useLatestRef";
5
6
 
6
7
  interface UseChainGraphDataProps {
7
8
  graph: ChainGraphServices | null;
@@ -28,6 +29,35 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
28
29
  );
29
30
  const [isLayouting, setIsLayouting] = useState(true);
30
31
 
32
+ // 这三个回调都是「交给图实例、由它在后续事件里回调」的出口,不是数据本身。
33
+ // 它们进依赖数组是条死依赖:effect 体被 ObjEqual(lastData, data) 挡住,
34
+ // 引用变了也不会重新 setData —— 图里存的还是首次 setData 时那个闭包,
35
+ // 换了引用永远调不到(stale closure)。改成引用恒定的转发函数:
36
+ // 依赖数组只留真正的输入(graph/data/level/rootLevel),调用时总是取到最新的回调。
37
+ const getImageRef = useLatestRef(getImage);
38
+ const labelRenderRef = useLatestRef(labelRender);
39
+ const onLayoutingChangeRef = useLatestRef(onLayoutingChange);
40
+
41
+ const emitImage = useCallback(
42
+ (img: string) => {
43
+ getImageRef.current?.(img);
44
+ },
45
+ [getImageRef]
46
+ );
47
+ // labelRender 在 ChainGraphServices.setData 里是「传了才改写 label」的真值判断,
48
+ // 所以这里保留「有没有传」的语义:没传就传 undefined,传了才给转发函数。
49
+ const renderLabel = useCallback(
50
+ (label: TreeGraphData) => labelRenderRef.current!(label),
51
+ [labelRenderRef]
52
+ );
53
+ const emitLayouting = useCallback(
54
+ (value: boolean) => {
55
+ setIsLayouting(value);
56
+ onLayoutingChangeRef.current?.(value);
57
+ },
58
+ [onLayoutingChangeRef]
59
+ );
60
+
31
61
  useEffect(() => {
32
62
  if (graph && !Equal.ObjEqual(lastData, data)) {
33
63
  setLastData(data);
@@ -35,12 +65,9 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
35
65
  data,
36
66
  level,
37
67
  rootLevel,
38
- getImage,
39
- isLayouting: (value) => {
40
- setIsLayouting(value);
41
- onLayoutingChange?.(value);
42
- },
43
- labelRender,
68
+ getImage: emitImage,
69
+ isLayouting: emitLayouting,
70
+ labelRender: labelRenderRef.current ? renderLabel : undefined,
44
71
  });
45
72
  }
46
73
  }, [
@@ -48,10 +75,11 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
48
75
  data,
49
76
  level,
50
77
  rootLevel,
51
- getImage,
52
- labelRender,
53
- onLayoutingChange,
54
78
  lastData,
79
+ emitImage,
80
+ emitLayouting,
81
+ renderLabel,
82
+ labelRenderRef,
55
83
  ]);
56
84
 
57
85
  return { isLayouting };