@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
@@ -13,7 +13,7 @@ var _TextArea = _interopRequireDefault(require("./TextArea"));
13
13
  var _classnames = _interopRequireDefault(require("classnames"));
14
14
  var _indexModule = _interopRequireDefault(require("./index.module.scss"));
15
15
  var _Range = _interopRequireDefault(require("./Range"));
16
- var _ahooks = require("ahooks");
16
+ var _useLatestRef = require("../../hooks/useLatestRef");
17
17
  var _jsxRuntime = require("react/jsx-runtime");
18
18
  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); }
19
19
  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; }
@@ -34,8 +34,11 @@ const Input = props => {
34
34
  escapeCharacters,
35
35
  ...inputConfig
36
36
  } = props;
37
- const [isComposing, setComposing] = (0, _react.useState)(false);
38
37
  const ref = (0, _react.useRef)(null);
38
+ const getRefRef = (0, _useLatestRef.useLatestRef)(getRef);
39
+ // 输入法组字期间不往外通知。放 ref 不放 state:它只决定「要不要发通知」,
40
+ // 不参与渲染,而且必须在同一次事件里立刻生效
41
+ const composingRef = (0, _react.useRef)(false);
39
42
 
40
43
  // Handle escaping: if the value contains characters listed in escapeCharacters, prefix them with an escape
41
44
  const escapeValue = (0, _react.useCallback)(val => {
@@ -104,20 +107,23 @@ const Input = props => {
104
107
  };
105
108
  const initialValue = getInitialValue();
106
109
  const [_value, setValue] = (0, _react.useState)(initialValue);
107
- const [lastValue, setLastValue] = (0, _react.useState)(initialValue);
108
110
  const prevValueRef = (0, _react.useRef)(undefined);
109
- (0, _ahooks.useDebounceEffect)(() => {
110
- if (!isComposing && _value !== lastValue) {
111
- const trimmedValue = _value.trim();
112
- const finalValue = trimmedValue === "" ? "" : _value;
113
- setLastValue(finalValue);
114
- // If escapeCharacters is set and the value matches, return the escaped value
115
- const escapedValue = escapeValue(finalValue);
116
- onChange?.(escapedValue);
117
- }
118
- }, [_value, isComposing, lastValue, onChange, escapeValue], {
119
- wait: 10
120
- });
111
+ // 最近一次通知出去的原文(未转义)。只用来去掉 compositionend 与 input
112
+ // 两个事件的重复通知
113
+ const notifiedRef = (0, _react.useRef)(initialValue);
114
+
115
+ /**
116
+ * `onChange` 是「用户改了输入」这个**事件**的通知,不是从 state 推导出来的结果,
117
+ * 所以在事件处理里发,不在 effect 里发。详见 `TextArea/index.tsx` 里那段说明——
118
+ * 旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
119
+ * 会让传内联箭头的消费方死循环。
120
+ */
121
+ const notify = next => {
122
+ if (next === notifiedRef.current) return;
123
+ notifiedRef.current = next;
124
+ // 全是空白等同于空;escapeCharacters 命中时对外给转义后的值
125
+ onChange?.(escapeValue(next.trim() === "" ? "" : next));
126
+ };
121
127
  (0, _react.useEffect)(() => {
122
128
  // Update internal state only when the external value prop actually changes
123
129
  if (prevValueRef.current !== value) {
@@ -126,18 +132,21 @@ const Input = props => {
126
132
  const rawValue = typeof value === "number" ? `${value}` : value?.toString();
127
133
  // Unescape before displaying
128
134
  const newValue = unescapeValue(rawValue);
135
+ // 外部把值改了,之前通知过什么就不作数了,重新以外部值为准
136
+ notifiedRef.current = newValue;
129
137
  setValue(newValue);
130
- setLastValue(newValue);
131
138
  } else {
132
139
  // Clear only on initialization or when explicitly set to undefined externally
140
+ notifiedRef.current = "";
133
141
  setValue("");
134
- setLastValue("");
135
142
  }
136
143
  }
137
144
  }, [value, unescapeValue]);
145
+
146
+ // 只在挂载时把 ref 交出去。`getRef` 进依赖数组同样会被内联箭头带着每次渲染重跑
138
147
  (0, _react.useEffect)(() => {
139
- getRef?.(ref.current);
140
- }, [getRef]);
148
+ getRefRef.current?.(ref.current);
149
+ }, [getRefRef]);
141
150
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_antd.Tooltip, {
142
151
  placement: "topLeft",
143
152
  ...tooltip,
@@ -145,11 +154,14 @@ const Input = props => {
145
154
  ref: ref,
146
155
  disabled: disabled,
147
156
  placeholder: disabled ? "" : placeholder,
148
- onCompositionStart: () => setComposing(true),
149
- onCompositionEnd: () => {
150
- setTimeout(() => {
151
- setComposing(false);
152
- }, 1);
157
+ onCompositionStart: () => {
158
+ composingRef.current = true;
159
+ },
160
+ onCompositionEnd: e => {
161
+ composingRef.current = false;
162
+ // 组完字的那一下自己发通知。浏览器之间 compositionend 与 input 的先后
163
+ // 不一致,`notify` 里按原文去重,两种顺序都只会发出一次
164
+ notify(unescapeValue(e.currentTarget.value));
153
165
  },
154
166
  value: _value,
155
167
  onChange: e => {
@@ -157,6 +169,7 @@ const Input = props => {
157
169
  const inputValue = e.target.value;
158
170
  const unescapedInput = unescapeValue(inputValue);
159
171
  setValue(unescapedInput);
172
+ if (!composingRef.current) notify(unescapedInput);
160
173
  },
161
174
  className: (0, _classnames.default)(_indexModule.default.antdInput, className),
162
175
  type: type,
@@ -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
  };
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useSelectInputOptions = useSelectInputOptions;
7
7
  var _react = require("react");
8
+ var _useLatestRef = require("../../../hooks/useLatestRef");
8
9
  /**
9
10
  * Manage input options (when there are no matching options)
10
11
  */
@@ -16,11 +17,15 @@ function useSelectInputOptions({
16
17
  optionsLength
17
18
  }) {
18
19
  const [inputOptions, setInputOptions] = (0, _react.useState)([]);
20
+ const onSearchRef = (0, _useLatestRef.useLatestRef)(onSearch);
21
+
22
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
23
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
19
24
  (0, _react.useEffect)(() => {
20
25
  if (!isComposing) {
21
- onSearch?.(searchValue);
26
+ onSearchRef.current?.(searchValue);
22
27
  }
23
- }, [inputOptions, isComposing, onChange, onSearch, optionsLength, searchValue]);
28
+ }, [inputOptions, isComposing, onSearchRef, optionsLength, searchValue]);
24
29
  return {
25
30
  inputOptions,
26
31
  setInputOptions
@@ -21,25 +21,37 @@ const Slider = props => {
21
21
  ...sliderConfig
22
22
  } = props;
23
23
  const [_value, setValue] = (0, _react.useState)(value);
24
- const [lastValue, setLastValue] = (0, _react.useState)(value);
25
- (0, _react.useEffect)(() => {
26
- if (_value !== lastValue) {
27
- setLastValue(_value);
28
- onChange?.(_value);
29
- }
30
- }, [lastValue, onChange, _value]);
24
+ // 最近一次通知出去的值,代替原来那个用 state 记账的写法
25
+ const notifiedRef = (0, _react.useRef)(value);
26
+
27
+ /**
28
+ * `onChange` 是「用户拖了滑块」这个**事件**的通知,不是从 state 推导出来的结果,
29
+ * 所以在事件处理里发,不在 effect 里发。详见 `Input/TextArea/index.tsx` 里那段
30
+ * 说明——旧写法(依赖数组里放 `onChange`、体内又调它、用 state 记「通知过没有」)
31
+ * 会让传内联箭头的消费方死循环。
32
+ */
33
+ const handleChange = next => {
34
+ setValue(next);
35
+ if (next === notifiedRef.current) return;
36
+ notifiedRef.current = next;
37
+ onChange?.(next);
38
+ };
39
+
40
+ // 外部值(`value` / `topValue`)变了才回写,不跟着通知走
31
41
  (0, _react.useEffect)(() => {
32
- if ((topValue || value) !== lastValue) {
33
- setValue(topValue || value);
42
+ const next = topValue || value;
43
+ if (next !== notifiedRef.current) {
44
+ notifiedRef.current = next;
45
+ setValue(next);
34
46
  }
35
- }, [lastValue, topValue, value]);
47
+ }, [topValue, value]);
36
48
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_antd.Slider, {
37
49
  className: (0, _classnames.default)([_indexModule.default.slider, className]),
38
50
  tooltip: {
39
51
  open: false
40
52
  },
41
53
  value: _value,
42
- onChange: setValue,
54
+ onChange: handleChange,
43
55
  ...sliderConfig
44
56
  });
45
57
  };
@@ -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;
@@ -7,6 +7,7 @@ exports.useUploadFileList = useUploadFileList;
7
7
  var _react = require("react");
8
8
  var _hsuUtils = require("hsu-utils");
9
9
  var _utils = require("../_utils");
10
+ var _useLatestRef = require("../../../hooks/useLatestRef");
10
11
  /**
11
12
  * Manage the upload file list state
12
13
  */
@@ -17,6 +18,10 @@ function useUploadFileList({
17
18
  }) {
18
19
  const [_fileList, setFilelist] = (0, _react.useState)([]);
19
20
  const [lastFileList, setLastFileList] = (0, _react.useState)([]);
21
+ const onChangeRef = (0, _useLatestRef.useLatestRef)(onChange);
22
+
23
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
24
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
20
25
  (0, _react.useEffect)(() => {
21
26
  if (rmFile) {
22
27
  const file = _fileList.find(item => item.uid === rmFile);
@@ -24,14 +29,14 @@ function useUploadFileList({
24
29
  if (file) {
25
30
  setFilelist(filteredList);
26
31
  setTimeout(() => {
27
- onChange?.({
32
+ onChangeRef.current?.({
28
33
  file,
29
34
  fileList: filteredList
30
35
  });
31
36
  }, 100);
32
37
  }
33
38
  }
34
- }, [_fileList, onChange, rmFile]);
39
+ }, [_fileList, onChangeRef, rmFile]);
35
40
  (0, _react.useEffect)(() => {
36
41
  if (!_hsuUtils.Equal.ObjEqual(fileList ?? [], lastFileList)) {
37
42
  if (fileList?.length) {
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.useUploadOperations = useUploadOperations;
7
7
  var _react = require("react");
8
8
  var _hsuUtils = require("hsu-utils");
9
+ var _useLatestRef = require("../../../hooks/useLatestRef");
9
10
  /**
10
11
  * Manage upload and download operations
11
12
  */
@@ -19,6 +20,7 @@ function useUploadOperations({
19
20
  downloading: {},
20
21
  uploadingList: {}
21
22
  });
23
+ const onUploadingListRef = (0, _useLatestRef.useLatestRef)(onUploadingList);
22
24
 
23
25
  // Sync to the ref on update
24
26
  (0, _react.useEffect)(() => {
@@ -27,12 +29,15 @@ function useUploadOperations({
27
29
  (0, _react.useEffect)(() => {
28
30
  operationsRef.current.uploadingList = uploadingList;
29
31
  }, [uploadingList]);
32
+
33
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
34
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
30
35
  (0, _react.useEffect)(() => {
31
36
  if (!_hsuUtils.Equal.ObjEqual(uploadingList, lastUploadList)) {
32
- onUploadingList?.((0, _hsuUtils.deepCopy)(uploadingList));
37
+ onUploadingListRef.current?.((0, _hsuUtils.deepCopy)(uploadingList));
33
38
  setLastUploadList((0, _hsuUtils.deepCopy)(uploadingList));
34
39
  }
35
- }, [uploadingList, lastUploadList, onUploadingList]);
40
+ }, [uploadingList, lastUploadList, onUploadingListRef]);
36
41
 
37
42
  // Cleanup function
38
43
  (0, _react.useEffect)(() => {
@@ -0,0 +1,24 @@
1
+ /// <reference types="react" />
2
+ /**
3
+ * 把一个每次渲染都可能换引用的值(通常是回调 prop)装进一个**引用恒定**的 ref。
4
+ *
5
+ * 用途只有一个:让 effect 不必把回调放进依赖数组。
6
+ * 回调放进依赖数组、effect 体内又调它,是一条会让消费方死循环的写法——
7
+ * 消费方传内联箭头(`onChange={(v) => setX(v)}`,React 里最常见的写法)时
8
+ * 每次渲染都是新引用 → effect 重跑 → 调回调 → 父级 setState → 再渲染 →
9
+ * 又是新引用 …… 直到 React 抛 `Maximum update depth exceeded`。
10
+ *
11
+ * ```ts
12
+ * const onDoneRef = useLatestRef(onDone);
13
+ * useEffect(() => {
14
+ * const t = setTimeout(() => onDoneRef.current?.(), 1000);
15
+ * return () => clearTimeout(t);
16
+ * }, [onDoneRef]); // 只在挂载时跑一次,且永远调到最新的 onDone
17
+ * ```
18
+ *
19
+ * 注意:渲染期间就地赋值。React 官方对 ref 的约束是「不要在渲染中**读**可变值」,
20
+ * 写入最新的 props 是 useEffectEvent 落地前的通行做法(ahooks 的 `useLatest`、
21
+ * React 文档里的 `useEvent` polyfill 都是这么写的)。
22
+ */
23
+ export declare function useLatestRef<T>(value: T): import("react").MutableRefObject<T>;
24
+ export default useLatestRef;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ exports.useLatestRef = useLatestRef;
8
+ var _react = require("react");
9
+ /**
10
+ * 把一个每次渲染都可能换引用的值(通常是回调 prop)装进一个**引用恒定**的 ref。
11
+ *
12
+ * 用途只有一个:让 effect 不必把回调放进依赖数组。
13
+ * 回调放进依赖数组、effect 体内又调它,是一条会让消费方死循环的写法——
14
+ * 消费方传内联箭头(`onChange={(v) => setX(v)}`,React 里最常见的写法)时
15
+ * 每次渲染都是新引用 → effect 重跑 → 调回调 → 父级 setState → 再渲染 →
16
+ * 又是新引用 …… 直到 React 抛 `Maximum update depth exceeded`。
17
+ *
18
+ * ```ts
19
+ * const onDoneRef = useLatestRef(onDone);
20
+ * useEffect(() => {
21
+ * const t = setTimeout(() => onDoneRef.current?.(), 1000);
22
+ * return () => clearTimeout(t);
23
+ * }, [onDoneRef]); // 只在挂载时跑一次,且永远调到最新的 onDone
24
+ * ```
25
+ *
26
+ * 注意:渲染期间就地赋值。React 官方对 ref 的约束是「不要在渲染中**读**可变值」,
27
+ * 写入最新的 props 是 useEffectEvent 落地前的通行做法(ahooks 的 `useLatest`、
28
+ * React 文档里的 `useEvent` polyfill 都是这么写的)。
29
+ */
30
+ function useLatestRef(value) {
31
+ const ref = (0, _react.useRef)(value);
32
+ ref.current = value;
33
+ return ref;
34
+ }
35
+ var _default = exports.default = useLatestRef;
@@ -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.3",
3
+ "version": "2.5.5",
4
4
  "description": "一套基于 React + Ant Design 的中后台业务组件库",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,9 @@
42
42
  "docs:build": "dumi build",
43
43
  "docs:preview": "dumi preview",
44
44
  "build": "node scripts/clean-dist.cjs && node scripts/gen-tokens-scss.cjs && father build && node scripts/mark-cjs.cjs && node scripts/check-heavy-deps.cjs",
45
- "prepublishOnly": "node scripts/clean-dist.cjs && node scripts/gen-tokens-scss.cjs --check && father build && node scripts/mark-cjs.cjs && node scripts/check-heavy-deps.cjs",
45
+ "prepublishOnly": "node scripts/clean-dist.cjs && node scripts/gen-tokens-scss.cjs --check && vitest run && father build && node scripts/mark-cjs.cjs && node scripts/check-heavy-deps.cjs",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
46
48
  "check:deps": "node scripts/check-heavy-deps.cjs",
47
49
  "tokens": "node scripts/gen-tokens-scss.cjs",
48
50
  "check:tokens": "node scripts/gen-tokens-scss.cjs --check"
@@ -115,6 +117,8 @@
115
117
  },
116
118
  "devDependencies": {
117
119
  "@ant-design/icons": "^6.3.2",
120
+ "@testing-library/dom": "^10",
121
+ "@testing-library/react": "^16",
118
122
  "@types/js-cookie": "^3.0.6",
119
123
  "@types/lodash": "^4.17.4",
120
124
  "@types/markdown-it": "^14.1.2",
@@ -126,6 +130,7 @@
126
130
  "antd": "6.5.4",
127
131
  "dumi": "^2.4.0",
128
132
  "father": "^4.5.0",
133
+ "jsdom": "^25",
129
134
  "mobx": "^6.12.3",
130
135
  "mobx-react-lite": "^4.0.7",
131
136
  "react": "^18.3.1",
@@ -134,7 +139,8 @@
134
139
  "react-router": "^6.23.1",
135
140
  "react-router-dom": "^6.23.1",
136
141
  "sass": "^1.101.0",
137
- "typescript": "^5.4.5"
142
+ "typescript": "^5.4.5",
143
+ "vitest": "^2"
138
144
  },
139
145
  "peerDependenciesMeta": {
140
146
  "react-router": {
@@ -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,26 @@
1
+ /**
2
+ * jsdom 没有 `matchMedia` / `ResizeObserver`,antd 与 rc-* 会直接调它们。
3
+ * 这里补齐,缺一个就整个测试文件起不来
4
+ */
5
+ import "@testing-library/react";
6
+
7
+ if (!window.matchMedia) {
8
+ window.matchMedia = ((query: string) => ({
9
+ matches: false,
10
+ media: query,
11
+ onchange: null,
12
+ addListener: () => {},
13
+ removeListener: () => {},
14
+ addEventListener: () => {},
15
+ removeEventListener: () => {},
16
+ dispatchEvent: () => false,
17
+ })) as unknown as typeof window.matchMedia;
18
+ }
19
+
20
+ if (!(globalThis as { ResizeObserver?: unknown }).ResizeObserver) {
21
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
22
+ observe() {}
23
+ unobserve() {}
24
+ disconnect() {}
25
+ };
26
+ }