@hsu-react/ui 2.5.4 → 2.5.6

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 (66) 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 +55 -20
  4. package/es/components/Chart/Bubble/index.js +21 -6
  5. package/es/components/Chart/Common/index.js +6 -1
  6. package/es/components/Chart/Gauge/index.js +6 -1
  7. package/es/components/Chart/Heatmap/index.js +24 -7
  8. package/es/components/Chart/Line/index.js +55 -20
  9. package/es/components/Chart/Pie/Pie3D/index.js +30 -12
  10. package/es/components/Chart/Pie/index.js +24 -7
  11. package/es/components/Chart/Polar/index.js +9 -2
  12. package/es/components/Chart/Radar/index.js +6 -1
  13. package/es/components/Chart/Sankey/index.js +11 -2
  14. package/es/components/Chart/Tree/index.js +11 -2
  15. package/es/components/Select/_hooks/useSelectComposition.js +6 -2
  16. package/es/components/Table/_hooks/useAutoScrolling.js +16 -7
  17. package/es/hooks/useShallowStable.d.ts +32 -0
  18. package/es/hooks/useShallowStable.js +54 -0
  19. package/es/layout/Menu/_hooks/useOnlyLvOneMenu.js +9 -2
  20. package/lib/components/ChainGraph/_hooks/useChainGraphData.js +24 -7
  21. package/lib/components/ChainGraph/_hooks/useChainGraphLayout.js +12 -2
  22. package/lib/components/Chart/Bar/index.js +51 -19
  23. package/lib/components/Chart/Bubble/index.js +20 -6
  24. package/lib/components/Chart/Common/index.js +6 -1
  25. package/lib/components/Chart/Gauge/index.js +6 -1
  26. package/lib/components/Chart/Heatmap/index.js +23 -7
  27. package/lib/components/Chart/Line/index.js +51 -19
  28. package/lib/components/Chart/Pie/Pie3D/index.js +29 -12
  29. package/lib/components/Chart/Pie/index.js +23 -7
  30. package/lib/components/Chart/Polar/index.js +9 -2
  31. package/lib/components/Chart/Radar/index.js +6 -1
  32. package/lib/components/Chart/Sankey/index.js +10 -2
  33. package/lib/components/Chart/Tree/index.js +10 -2
  34. package/lib/components/Select/_hooks/useSelectComposition.js +6 -2
  35. package/lib/components/Table/_hooks/useAutoScrolling.js +16 -7
  36. package/lib/hooks/useShallowStable.d.ts +32 -0
  37. package/lib/hooks/useShallowStable.js +59 -0
  38. package/lib/layout/Menu/_hooks/useOnlyLvOneMenu.js +8 -2
  39. package/package.json +1 -1
  40. package/src/__tests__/callbackDepsGuard.test.ts +72 -0
  41. package/src/__tests__/depsScan.ts +158 -0
  42. package/src/__tests__/freshObjectDepsGuard.test.ts +144 -0
  43. package/src/components/ChainGraph/_hooks/callbacks.test.tsx +155 -0
  44. package/src/components/ChainGraph/_hooks/useChainGraphData.ts +38 -10
  45. package/src/components/ChainGraph/_hooks/useChainGraphLayout.ts +16 -3
  46. package/src/components/Chart/Bar/index.tsx +65 -22
  47. package/src/components/Chart/Bubble/index.tsx +21 -6
  48. package/src/components/Chart/Common/index.tsx +6 -1
  49. package/src/components/Chart/Gauge/index.tsx +6 -1
  50. package/src/components/Chart/Heatmap/index.tsx +39 -17
  51. package/src/components/Chart/Line/index.tsx +65 -22
  52. package/src/components/Chart/Pie/Pie3D/index.tsx +27 -8
  53. package/src/components/Chart/Pie/index.tsx +25 -7
  54. package/src/components/Chart/Polar/index.tsx +10 -2
  55. package/src/components/Chart/Radar/index.tsx +6 -1
  56. package/src/components/Chart/Sankey/index.tsx +10 -4
  57. package/src/components/Chart/Tree/index.tsx +10 -4
  58. package/src/components/Chart/callbacks.test.tsx +315 -0
  59. package/src/components/Chart/optionMemo.test.tsx +232 -0
  60. package/src/components/Select/_hooks/useSelectComposition.test.ts +47 -0
  61. package/src/components/Select/_hooks/useSelectComposition.ts +6 -2
  62. package/src/components/Table/_hooks/useAutoScrolling.test.tsx +95 -0
  63. package/src/components/Table/_hooks/useAutoScrolling.ts +16 -7
  64. package/src/hooks/useShallowStable.ts +65 -0
  65. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.test.tsx +56 -0
  66. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.ts +8 -2
@@ -11,10 +11,13 @@ var _chartUtils = require("../chartUtils");
11
11
  var _indexModule = _interopRequireDefault(require("../index.module.scss"));
12
12
  var _Pie3D = _interopRequireDefault(require("./Pie3D"));
13
13
  var _useLatestRef = require("../../../hooks/useLatestRef");
14
+ var _useShallowStable = _interopRequireDefault(require("../../../hooks/useShallowStable"));
14
15
  var _jsxRuntime = require("react/jsx-runtime");
15
16
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
16
17
  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); }
17
18
  function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
19
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
20
+ const EMPTY_EXTEND_SERIES = [];
18
21
  const ChartPie = props => {
19
22
  const {
20
23
  className,
@@ -26,7 +29,7 @@ const ChartPie = props => {
26
29
  series,
27
30
  title,
28
31
  onChart,
29
- extendSeries = [],
32
+ extendSeries = EMPTY_EXTEND_SERIES,
30
33
  onClick,
31
34
  enableLegendAutoScroll = false,
32
35
  legendVisibleCount = 8,
@@ -36,8 +39,12 @@ const ChartPie = props => {
36
39
  spanAngle = 180,
37
40
  center,
38
41
  radius,
39
- ...coreOption
42
+ ...restOption
40
43
  } = props;
44
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
45
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
46
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
47
+ const coreOption = (0, _useShallowStable.default)(restOption);
41
48
  const chartRef = (0, _react.useRef)(null);
42
49
  const chartInstanceRef = (0, _react.useRef)(null);
43
50
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -203,6 +210,12 @@ const ChartPie = props => {
203
210
  }, []);
204
211
  const onChartRef = (0, _useLatestRef.useLatestRef)(onChart);
205
212
 
213
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
214
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
215
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
216
+ const onClickRef = (0, _useLatestRef.useLatestRef)(onClick);
217
+ const hasOnClick = !!onClick;
218
+
206
219
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
207
220
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
208
221
  // Initialize the chart
@@ -231,8 +244,11 @@ const ChartPie = props => {
231
244
  }
232
245
 
233
246
  // Add click event
234
- if (onClick) {
235
- chartInstanceRef.current?.on("click", onClick);
247
+ const handleClick = event => {
248
+ onClickRef.current?.(event);
249
+ };
250
+ if (hasOnClick) {
251
+ chartInstanceRef.current?.on("click", handleClick);
236
252
  }
237
253
 
238
254
  // Legend auto-scroll
@@ -253,8 +269,8 @@ const ChartPie = props => {
253
269
  // Cleanup function
254
270
  return () => {
255
271
  window.removeEventListener("resize", handleResize);
256
- if (onClick) {
257
- chartInstanceRef.current?.off("click", onClick);
272
+ if (hasOnClick) {
273
+ chartInstanceRef.current?.off("click", handleClick);
258
274
  }
259
275
 
260
276
  // Clean up legend scrolling
@@ -263,7 +279,7 @@ const ChartPie = props => {
263
279
  legendScrollRef.current = null;
264
280
  }
265
281
  };
266
- }, [chartOption, handleResize, onChartRef, onClick, enableLegendAutoScroll, seriesData, legendVisibleCount, legendScrollInterval, containerReady]);
282
+ }, [chartOption, handleResize, onChartRef, onClickRef, hasOnClick, enableLegendAutoScroll, seriesData, legendVisibleCount, legendScrollInterval, containerReady]);
267
283
 
268
284
  // Clean up resources when the component unmounts
269
285
  (0, _react.useEffect)(() => {
@@ -8,10 +8,13 @@ var echarts = _interopRequireWildcard(require("echarts"));
8
8
  var _useContainerReady = _interopRequireDefault(require("../_hooks/useContainerReady"));
9
9
  var _react = _interopRequireWildcard(require("react"));
10
10
  var _indexModule = _interopRequireDefault(require("../index.module.scss"));
11
+ var _useShallowStable = _interopRequireDefault(require("../../../hooks/useShallowStable"));
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); }
14
15
  function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
16
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
17
+ const DEFAULT_COLOR = ["#00FFA3", "#00FFA3"];
15
18
  const Polar = props => {
16
19
  const {
17
20
  className,
@@ -26,9 +29,13 @@ const Polar = props => {
26
29
  title2Style,
27
30
  textStyle,
28
31
  seriesData,
29
- color = ["#00FFA3", "#00FFA3"],
30
- ...coreOption
32
+ color = DEFAULT_COLOR,
33
+ ...restOption
31
34
  } = props;
35
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
36
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
37
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
38
+ const coreOption = (0, _useShallowStable.default)(restOption);
32
39
  const chartRef = (0, _react.useRef)(null);
33
40
  const chartInstanceRef = (0, _react.useRef)(null);
34
41
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -8,6 +8,7 @@ var echarts = _interopRequireWildcard(require("echarts"));
8
8
  var _useContainerReady = _interopRequireDefault(require("../_hooks/useContainerReady"));
9
9
  var _react = _interopRequireWildcard(require("react"));
10
10
  var _indexModule = _interopRequireDefault(require("../index.module.scss"));
11
+ var _useShallowStable = _interopRequireDefault(require("../../../hooks/useShallowStable"));
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); }
@@ -30,8 +31,12 @@ const Radar = props => {
30
31
  singleIndicatorTooltip: singleIndicatorTooltipProp = false,
31
32
  singleIndicatorMode,
32
33
  singleIndicatorHoverFill,
33
- ...coreOption
34
+ ...restOption
34
35
  } = props;
36
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
37
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
38
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
39
+ const coreOption = (0, _useShallowStable.default)(restOption);
35
40
  const chartRef = (0, _react.useRef)(null);
36
41
  const chartInstanceRef = (0, _react.useRef)(null);
37
42
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -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;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 把一个**内容没变、但每次渲染都新建**的对象收敛成**引用恒定**的对象:
3
+ * 浅比较(自有键 + `Object.is` 比值)相等就继续返回上一次那个引用。
4
+ *
5
+ * 用途只有一个:让它可以直接进 `useMemo` / `useEffect` 的依赖数组。
6
+ *
7
+ * React 的依赖数组按 `Object.is` 比引用,所以依赖里只要混进一个「每次渲染都新建
8
+ * 的对象」,memo 就**恒不命中**、effect 就**每渲染必重跑**。组件内部最容易出现
9
+ * 这种对象的地方是 rest 解构:
10
+ *
11
+ * ```tsx
12
+ * const { className, style, ...coreOption } = props; // coreOption 每次都是新对象
13
+ * const option = useMemo(() => ({ ...coreOption }), [coreOption]); // 永远不命中
14
+ * ```
15
+ *
16
+ * 消费方就算把每一个 prop 都 memo 好,也躲不掉——新对象是组件自己建的。
17
+ * 正确写法是让这个对象回到值语义:
18
+ *
19
+ * ```tsx
20
+ * const { className, style, ...restOption } = props;
21
+ * const coreOption = useShallowStable(restOption); // 内容不变 → 引用不变
22
+ * ```
23
+ *
24
+ * 注意这**不是**「缓存结果跳过副作用」:memo 该重算时照样重算、effect 该重跑时
25
+ * 照样重跑——只要 rest 里任何一个值真的变了,浅比较就不相等,新引用立刻透出去。
26
+ *
27
+ * 渲染期间读写 ref 的安全性:并发渲染下被丢弃的那次渲染可能已经把 ref 换成了它
28
+ * 自己的对象,但那个对象与当次 props 浅相等,所以后续渲染拿到的引用**内容永远
29
+ * 与当前 props 一致**,不会读到过期值。
30
+ */
31
+ export declare function useShallowStable<T extends object>(value: T): T;
32
+ export default useShallowStable;
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ exports.useShallowStable = useShallowStable;
8
+ var _react = require("react");
9
+ /**
10
+ * 把一个**内容没变、但每次渲染都新建**的对象收敛成**引用恒定**的对象:
11
+ * 浅比较(自有键 + `Object.is` 比值)相等就继续返回上一次那个引用。
12
+ *
13
+ * 用途只有一个:让它可以直接进 `useMemo` / `useEffect` 的依赖数组。
14
+ *
15
+ * React 的依赖数组按 `Object.is` 比引用,所以依赖里只要混进一个「每次渲染都新建
16
+ * 的对象」,memo 就**恒不命中**、effect 就**每渲染必重跑**。组件内部最容易出现
17
+ * 这种对象的地方是 rest 解构:
18
+ *
19
+ * ```tsx
20
+ * const { className, style, ...coreOption } = props; // coreOption 每次都是新对象
21
+ * const option = useMemo(() => ({ ...coreOption }), [coreOption]); // 永远不命中
22
+ * ```
23
+ *
24
+ * 消费方就算把每一个 prop 都 memo 好,也躲不掉——新对象是组件自己建的。
25
+ * 正确写法是让这个对象回到值语义:
26
+ *
27
+ * ```tsx
28
+ * const { className, style, ...restOption } = props;
29
+ * const coreOption = useShallowStable(restOption); // 内容不变 → 引用不变
30
+ * ```
31
+ *
32
+ * 注意这**不是**「缓存结果跳过副作用」:memo 该重算时照样重算、effect 该重跑时
33
+ * 照样重跑——只要 rest 里任何一个值真的变了,浅比较就不相等,新引用立刻透出去。
34
+ *
35
+ * 渲染期间读写 ref 的安全性:并发渲染下被丢弃的那次渲染可能已经把 ref 换成了它
36
+ * 自己的对象,但那个对象与当次 props 浅相等,所以后续渲染拿到的引用**内容永远
37
+ * 与当前 props 一致**,不会读到过期值。
38
+ */
39
+ function useShallowStable(value) {
40
+ const ref = (0, _react.useRef)(value);
41
+ if (!shallowEqual(ref.current, value)) {
42
+ ref.current = value;
43
+ }
44
+ return ref.current;
45
+ }
46
+ function shallowEqual(a, b) {
47
+ if (Object.is(a, b)) return true;
48
+ const aKeys = Object.keys(a);
49
+ const bKeys = Object.keys(b);
50
+ if (aKeys.length !== bKeys.length) return false;
51
+ for (const key of aKeys) {
52
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
53
+ if (!Object.is(a[key], b[key])) {
54
+ return false;
55
+ }
56
+ }
57
+ return true;
58
+ }
59
+ var _default = exports.default = useShallowStable;
@@ -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.6",
4
4
  "description": "一套基于 React + Ant Design 的中后台业务组件库",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,72 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { hookDeps, walkSources } from "./depsScan";
5
+
6
+ /**
7
+ * 防回归守卫:**回调 prop 不许进 effect 依赖数组**。
8
+ *
9
+ * 这条缺陷没有任何信号——不报错、不变慢,只是消费方一传内联箭头
10
+ * (`onXxx={(v) => setV(v)}`,React 里最常见的写法)就重复订阅 / 重复通知,
11
+ * 严重的直接死循环到 `Maximum update depth exceeded`。
12
+ * 靠人工 review 拦不住,所以扫源码。
13
+ *
14
+ * 正确写法见 `src/hooks/useLatestRef.ts`:
15
+ * - 调用取 ref 里的最新引用;
16
+ * - 「有没有传回调」若决定要不要注册监听,就单独拆一个布尔量进依赖数组,
17
+ * 这样「从无到有传入回调」仍会正确注册。
18
+ *
19
+ * 确实无害的写法登记在 ALLOWED 里,每条都要写清为什么无害。
20
+ */
21
+
22
+ const SRC = path.resolve(__dirname, "..");
23
+
24
+ /** 回调型标识符:`onXxx` 形态,外加几个明确的出口型回调名 */
25
+ const EXTRA_CALLBACK_NAMES = new Set(["getImage", "labelRender"]);
26
+ const isCallbackDep = (name: string) =>
27
+ (/^on[A-Z]/.test(name) && !name.endsWith("Ref")) ||
28
+ EXTRA_CALLBACK_NAMES.has(name);
29
+
30
+ /** `文件相对路径::依赖名` → 无害的理由 */
31
+ const ALLOWED: Record<string, string> = {
32
+ "components/Chat/ChatHistory/index.tsx::onScrollEnd":
33
+ "只在 DOM scroll 监听里调,effect 体不调它;重订阅是同一节点上摘一个装一个,无副作用",
34
+ "components/DatePicker/index.tsx::onChange":
35
+ "纯 ref 同步 effect:effect 体只写 ref,不调回调、不订阅任何东西",
36
+ "components/DatePicker/RangePicker/index.tsx::onChange":
37
+ "同上,纯 ref 同步 effect",
38
+ "components/Tree/index.tsx::onSelectPath": "同上,纯 ref 同步 effect",
39
+ };
40
+
41
+ const EFFECT_RE = /use(?:Layout)?Effect\(/g;
42
+ const effectDeps = (src: string) => hookDeps(src, EFFECT_RE);
43
+
44
+ describe("回调 prop 不进 effect 依赖数组", () => {
45
+ it("全库无未登记的命中", () => {
46
+ const offenders: string[] = [];
47
+
48
+ for (const file of walkSources(SRC)) {
49
+ const rel = path.relative(SRC, file).split(path.sep).join("/");
50
+ const src = fs.readFileSync(file, "utf8");
51
+
52
+ for (const [line, dep] of effectDeps(src)) {
53
+ if (!isCallbackDep(dep)) continue;
54
+ if (ALLOWED[`${rel}::${dep}`]) continue;
55
+ offenders.push(`${rel}:${line} ${dep}`);
56
+ }
57
+ }
58
+
59
+ expect(offenders).toEqual([]);
60
+ });
61
+
62
+ it("守卫本身能抓到问题(自测)", () => {
63
+ const bad = `
64
+ useEffect(() => {
65
+ onDone?.();
66
+ }, [value, onDone]);
67
+ `;
68
+ expect(effectDeps(bad).map(([, d]) => d)).toContain("onDone");
69
+ expect(isCallbackDep("onDone")).toBe(true);
70
+ expect(isCallbackDep("onDoneRef")).toBe(false);
71
+ });
72
+ });