@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
@@ -8,6 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
8
8
  import useContainerReady from "../_hooks/useContainerReady";
9
9
  import styles from "../index.module.scss";
10
10
  import * as echarts from "echarts";
11
+ import useLatestRef from "../../../hooks/useLatestRef";
11
12
  import { deepCopy } from "hsu-utils";
12
13
  import { handleTreeData } from "../_utils/tree";
13
14
  import { jsx as _jsx } from "react/jsx-runtime";
@@ -38,6 +39,13 @@ var Tree = function Tree(props) {
38
39
  return option;
39
40
  }, [seriesData, series]);
40
41
 
42
+ // getImage 只是「出图后把 dataURL 交出去」的出口,既不决定图怎么画,
43
+ // 也不该决定要不要重画。它进依赖数组会让 effect 跟着重跑 → setOption(notMerge)
44
+ // → 图重绘 → 再触发一次 finished → 再回调;消费方把图存进 state 就是死循环。
45
+ // 而且 finished 监听只在首次 init 时注册,闭包里锁死的是首帧的 getImage,
46
+ // 后续换了引用永远调不到 —— 依赖数组写了它也没用,是条死依赖。走 ref 两个问题一起解决。
47
+ var getImageRef = useLatestRef(getImage);
48
+
41
49
  // Callback handling chart resize
42
50
  var handleResize = useCallback(function () {
43
51
  var _chartInstanceRef$cur;
@@ -60,7 +68,8 @@ var Tree = function Tree(props) {
60
68
 
61
69
  // Attach the finished event listener on first initialization
62
70
  chart.on("finished", function () {
63
- getImage === null || getImage === void 0 || getImage(chart.getDataURL({
71
+ var _getImageRef$current;
72
+ (_getImageRef$current = getImageRef.current) === null || _getImageRef$current === void 0 || _getImageRef$current.call(getImageRef, chart.getDataURL({
64
73
  type: "png",
65
74
  pixelRatio: 1,
66
75
  backgroundColor: "#fff"
@@ -84,7 +93,7 @@ var Tree = function Tree(props) {
84
93
  return function () {
85
94
  window.removeEventListener("resize", handleResize);
86
95
  };
87
- }, [chartOption, handleResize, getImage, containerReady]);
96
+ }, [chartOption, handleResize, getImageRef, containerReady]);
88
97
 
89
98
  // Clean up resources on unmount
90
99
  useEffect(function () {
@@ -14,6 +14,7 @@ export function useSelectComposition(_ref) {
14
14
  _useState2 = _slicedToArray(_useState, 2),
15
15
  isComposing = _useState2[0],
16
16
  setComposing = _useState2[1];
17
+ var hasOnSearch = !!onSearch;
17
18
  useEffect(function () {
18
19
  var compositionend = function compositionend() {
19
20
  setComposing(false);
@@ -21,7 +22,7 @@ export function useSelectComposition(_ref) {
21
22
  var compositionstart = function compositionstart() {
22
23
  setComposing(true);
23
24
  };
24
- if (onSearch) {
25
+ if (hasOnSearch) {
25
26
  window.addEventListener("compositionstart", compositionstart);
26
27
  window.addEventListener("compositionend", compositionend);
27
28
  }
@@ -29,7 +30,10 @@ export function useSelectComposition(_ref) {
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
  return {
34
38
  isComposing: isComposing
35
39
  };
@@ -7,6 +7,7 @@ function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" !=
7
7
  function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
8
8
  import { useMutationObserver } from "ahooks";
9
9
  import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { useLatestRef } from "../../../hooks/useLatestRef";
10
11
  var DEFAULT_INTERVAL = 2000;
11
12
  var DEFAULT_SPEED = 25;
12
13
  var SMOOTH_SCROLL_TICK = 10;
@@ -29,6 +30,13 @@ var useAutoScrolling = function useAutoScrolling(props) {
29
30
  autoScrollLoopMode = _props$autoScrollLoop2 === void 0 ? "reset" : _props$autoScrollLoop2,
30
31
  _props$autoScrollingO = props.autoScrollingOffset,
31
32
  autoScrollingOffset = _props$autoScrollingO === void 0 ? 0 : _props$autoScrollingO;
33
+
34
+ // onAutoScrollEndAdd 在这里身兼两职:既是「有没有加载更多」的开关,又是真正去加载的动作。
35
+ // 整个 rAF 循环(loop → 依赖它 → 启停 effect 依赖 loop)都挂在它的引用上,
36
+ // 消费方传内联箭头就会每渲染一次重启一次滚动循环 —— 表格滚到一半被打回起点。
37
+ // 拆成两半:开关看布尔量(从无到有传入时仍会正确接上加载更多),动作取 ref 里的最新引用。
38
+ var onAutoScrollEndAddRef = useLatestRef(onAutoScrollEndAdd);
39
+ var hasOnAutoScrollEndAdd = !!onAutoScrollEndAdd;
32
40
  var validSpeed = autoScrollingSpeed > 0 ? autoScrollingSpeed : DEFAULT_SPEED;
33
41
  var smoothStep = validSpeed * SMOOTH_SCROLL_TICK / 1000;
34
42
  var _useState = useState(false),
@@ -260,11 +268,12 @@ var useAutoScrolling = function useAutoScrolling(props) {
260
268
  }, SMOOTH_SCROLL_TICK);
261
269
  }, [bindHover, checkSeamlessReset, clearSmoothTimer, getBody, interval, isAtBottom, isBodyScrollable, isSeamless, smoothStep]);
262
270
  var runLoadMore = useCallback(function () {
263
- if (!onAutoScrollEndAdd || pendingLoadMoreRef.current) return;
271
+ var loadMore = onAutoScrollEndAddRef.current;
272
+ if (!loadMore || pendingLoadMoreRef.current) return;
264
273
  pendingLoadMoreRef.current = true;
265
274
  phaseRef.current = "loading";
266
275
  var token = ++loadTokenRef.current;
267
- onAutoScrollEndAdd().then(function (hasMore) {
276
+ loadMore().then(function (hasMore) {
268
277
  if (token !== loadTokenRef.current) return;
269
278
  var currentBody = getBody();
270
279
  if (hasMore) {
@@ -284,7 +293,7 @@ var useAutoScrolling = function useAutoScrolling(props) {
284
293
  if (token !== loadTokenRef.current) return;
285
294
  pendingLoadMoreRef.current = false;
286
295
  });
287
- }, [autoScrollLoop, getBody, onAutoScrollEndAdd]);
296
+ }, [autoScrollLoop, getBody, onAutoScrollEndAddRef]);
288
297
  var loop = useCallback(function () {
289
298
  rafRef.current = requestAnimationFrame(loop);
290
299
  if (!autoScrolling || !ready || !(dataSource !== null && dataSource !== void 0 && dataSource.length) || !(ref !== null && ref !== void 0 && ref.current)) {
@@ -316,7 +325,7 @@ var useAutoScrolling = function useAutoScrolling(props) {
316
325
  // In seamless mode the scroll never actually reaches the bottom; just keep scrolling
317
326
  phaseRef.current = "idle";
318
327
  } else if (isAtBottom(body)) {
319
- if (onAutoScrollEndAdd) {
328
+ if (hasOnAutoScrollEndAdd) {
320
329
  runLoadMore();
321
330
  } else {
322
331
  if (autoScrollLoop) {
@@ -362,7 +371,7 @@ var useAutoScrolling = function useAutoScrolling(props) {
362
371
  return;
363
372
  }
364
373
  startSmoothStep(body, targetTop);
365
- }, [autoScrollMode, autoScrollLoop, autoScrolling, bindHover, checkSeamlessReset, dataSource, getBody, getRowHeight, interval, isAtBottom, isBodyScrollable, isSeamless, onAutoScrollEndAdd, ready, ref, runLoadMore, setupSeamlessClones, startSmoothStep]);
374
+ }, [autoScrollMode, autoScrollLoop, autoScrolling, bindHover, checkSeamlessReset, dataSource, getBody, getRowHeight, interval, isAtBottom, isBodyScrollable, isSeamless, hasOnAutoScrollEndAdd, ready, ref, runLoadMore, setupSeamlessClones, startSmoothStep]);
366
375
  useEffect(function () {
367
376
  if (!autoScrolling || !ready || !(dataSource !== null && dataSource !== void 0 && dataSource.length) || !(ref !== null && ref !== void 0 && ref.current)) {
368
377
  cleanup();
@@ -387,7 +396,7 @@ var useAutoScrolling = function useAutoScrolling(props) {
387
396
  cleanupClones();
388
397
  return;
389
398
  }
390
- if (onAutoScrollEndAdd) {
399
+ if (hasOnAutoScrollEndAdd) {
391
400
  // In load-more mode, if scrolling had stopped, resume auto-scrolling when new data arrives
392
401
  if (phaseRef.current === "stopped") {
393
402
  phaseRef.current = autoScrollMode === "row" ? "wait" : "idle";
@@ -400,7 +409,7 @@ var useAutoScrolling = function useAutoScrolling(props) {
400
409
  phaseRef.current = autoScrollMode === "row" ? "wait" : "idle";
401
410
  waitUntilRef.current = autoScrollMode === "row" ? performance.now() + interval : 0;
402
411
  }
403
- }, [autoScrollMode, cleanupClones, dataSource, getBody, interval, isBodyScrollable, isSeamless, onAutoScrollEndAdd]);
412
+ }, [autoScrollMode, cleanupClones, dataSource, getBody, interval, isBodyScrollable, isSeamless, hasOnAutoScrollEndAdd]);
404
413
  useEffect(function () {
405
414
  if (!autoScrolling || !(ref !== null && ref !== void 0 && ref.current)) return;
406
415
  var resizeRaf = null;
@@ -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
  /**
4
5
  * Handle the top-level-only menu logic
@@ -10,16 +11,22 @@ export var useOnlyLvOneMenu = function useOnlyLvOneMenu(_ref) {
10
11
  setOpenkeys = _ref.setOpenkeys,
11
12
  setMenuKey = _ref.setMenuKey;
12
13
  var location = useLocation();
14
+ // getCurrChildItems 是 effect 体里发出去的通知,本身进依赖数组是这一类死循环的标准配方:
15
+ // 消费方传内联箭头 → 每次渲染新引用 → effect 重跑 → setOpenkeys([item.key]) 每次都是
16
+ // 新数组必定触发重渲染 → 又是新引用 …… 直到 React 抛 Maximum update depth exceeded。
17
+ // 通知取 ref 里的最新引用,依赖数组只留真正的输入。
18
+ var getCurrChildItemsRef = useLatestRef(getCurrChildItems);
13
19
  useEffect(function () {
14
20
  if (onlyLvOneMenu) {
15
21
  var item = items.find(function (i) {
16
22
  return i.key === "/" + location.pathname.split("/").filter(Boolean)[0];
17
23
  });
18
24
  if (item) {
19
- getCurrChildItems === null || getCurrChildItems === void 0 || getCurrChildItems(item.children || []);
25
+ var _getCurrChildItemsRef;
26
+ (_getCurrChildItemsRef = getCurrChildItemsRef.current) === null || _getCurrChildItemsRef === void 0 || _getCurrChildItemsRef.call(getCurrChildItemsRef, item.children || []);
20
27
  setOpenkeys([item.key]);
21
28
  setMenuKey(item.key);
22
29
  }
23
30
  }
24
- }, [items, getCurrChildItems, location, onlyLvOneMenu, setOpenkeys, setMenuKey]);
31
+ }, [items, getCurrChildItemsRef, location, onlyLvOneMenu, setOpenkeys, setMenuKey]);
25
32
  };
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.useChainGraphData = useChainGraphData;
7
7
  var _react = require("react");
8
8
  var _hsuUtils = require("hsu-utils");
9
+ var _useLatestRef = require("../../../hooks/useLatestRef");
9
10
  function useChainGraphData(props) {
10
11
  const {
11
12
  graph,
@@ -18,6 +19,25 @@ function useChainGraphData(props) {
18
19
  } = props;
19
20
  const [lastData, setLastData] = (0, _react.useState)(undefined);
20
21
  const [isLayouting, setIsLayouting] = (0, _react.useState)(true);
22
+
23
+ // 这三个回调都是「交给图实例、由它在后续事件里回调」的出口,不是数据本身。
24
+ // 它们进依赖数组是条死依赖:effect 体被 ObjEqual(lastData, data) 挡住,
25
+ // 引用变了也不会重新 setData —— 图里存的还是首次 setData 时那个闭包,
26
+ // 换了引用永远调不到(stale closure)。改成引用恒定的转发函数:
27
+ // 依赖数组只留真正的输入(graph/data/level/rootLevel),调用时总是取到最新的回调。
28
+ const getImageRef = (0, _useLatestRef.useLatestRef)(getImage);
29
+ const labelRenderRef = (0, _useLatestRef.useLatestRef)(labelRender);
30
+ const onLayoutingChangeRef = (0, _useLatestRef.useLatestRef)(onLayoutingChange);
31
+ const emitImage = (0, _react.useCallback)(img => {
32
+ getImageRef.current?.(img);
33
+ }, [getImageRef]);
34
+ // labelRender 在 ChainGraphServices.setData 里是「传了才改写 label」的真值判断,
35
+ // 所以这里保留「有没有传」的语义:没传就传 undefined,传了才给转发函数。
36
+ const renderLabel = (0, _react.useCallback)(label => labelRenderRef.current(label), [labelRenderRef]);
37
+ const emitLayouting = (0, _react.useCallback)(value => {
38
+ setIsLayouting(value);
39
+ onLayoutingChangeRef.current?.(value);
40
+ }, [onLayoutingChangeRef]);
21
41
  (0, _react.useEffect)(() => {
22
42
  if (graph && !_hsuUtils.Equal.ObjEqual(lastData, data)) {
23
43
  setLastData(data);
@@ -25,15 +45,12 @@ function useChainGraphData(props) {
25
45
  data,
26
46
  level,
27
47
  rootLevel,
28
- getImage,
29
- isLayouting: value => {
30
- setIsLayouting(value);
31
- onLayoutingChange?.(value);
32
- },
33
- labelRender
48
+ getImage: emitImage,
49
+ isLayouting: emitLayouting,
50
+ labelRender: labelRenderRef.current ? renderLabel : undefined
34
51
  });
35
52
  }
36
- }, [graph, data, level, rootLevel, getImage, labelRender, onLayoutingChange, lastData]);
53
+ }, [graph, data, level, rootLevel, lastData, emitImage, emitLayouting, renderLabel, labelRenderRef]);
37
54
  return {
38
55
  isLayouting
39
56
  };
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useChainGraphLayout = useChainGraphLayout;
7
7
  var _react = require("react");
8
+ var _useLatestRef = require("../../../hooks/useLatestRef");
8
9
  function useChainGraphLayout(props) {
9
10
  const {
10
11
  graph,
@@ -12,9 +13,18 @@ function useChainGraphLayout(props) {
12
13
  rootLevel,
13
14
  getImage
14
15
  } = props;
16
+
17
+ // getImage 只是「布局完成后把截图 dataURL 交出去」的出口,不是布局的输入。
18
+ // 它进依赖数组会让消费方每传一次内联箭头就重新 changeLayout 一次 —— 整张图重排;
19
+ // 消费方再把截图存进 state,就变成「重排 → 出图 → setState → 新引用 → 再重排」的死循环。
20
+ // 用引用恒定的转发函数注册,调用时再取最新的 getImage。
21
+ const getImageRef = (0, _useLatestRef.useLatestRef)(getImage);
22
+ const emitImage = (0, _react.useCallback)(img => {
23
+ getImageRef.current?.(img);
24
+ }, [getImageRef]);
15
25
  (0, _react.useEffect)(() => {
16
26
  if (graph && octopus !== undefined) {
17
- graph.changeLayout(octopus, rootLevel, getImage);
27
+ graph.changeLayout(octopus, rootLevel, emitImage);
18
28
  }
19
- }, [graph, octopus, rootLevel, getImage]);
29
+ }, [graph, octopus, rootLevel, emitImage]);
20
30
  }
@@ -13,6 +13,7 @@ var _autoSmooth = require("../_utils/autoSmooth");
13
13
  var _cartesian = require("../_utils/cartesian");
14
14
  var _chartTheme = require("../_utils/chartTheme");
15
15
  var _useIsDark = _interopRequireDefault(require("../../../hooks/useIsDark"));
16
+ var _useLatestRef = _interopRequireDefault(require("../../../hooks/useLatestRef"));
16
17
  var _jsxRuntime = require("react/jsx-runtime");
17
18
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
18
19
  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); }
@@ -57,6 +58,29 @@ const ChartBar = props => {
57
58
  const isDark = (0, _useIsDark.default)();
58
59
  const chrome = (0, _react.useMemo)(() => (0, _chartTheme.resolveChartChrome)(isDark), [isDark]);
59
60
 
61
+ // 回调 prop 一律走 useLatestRef,不进依赖数组:消费方传内联箭头(React 里最常见的
62
+ // 写法)时每次渲染都是新引用,进依赖数组会让「init + setOption(notMerge)」整段重跑,
63
+ // 还会把当前可视窗口再同步一次给父级 —— 父级据此改 state 就是死循环。
64
+ // 但「有没有传回调」决定要不要注册监听,所以单独拆成布尔量进依赖数组,
65
+ // 这样「从无到有传入回调」仍会重新注册,不会漏。
66
+ const onClickRef = (0, _useLatestRef.default)(onClick);
67
+ const onLegendSelectChangedRef = (0, _useLatestRef.default)(onLegendSelectChanged);
68
+ const onDataZoomWindowChangedRef = (0, _useLatestRef.default)(onDataZoomWindowChanged);
69
+ const hasOnClick = !!onClick;
70
+ const hasOnLegendSelectChanged = !!onLegendSelectChanged;
71
+ const hasOnDataZoomWindowChanged = !!onDataZoomWindowChanged;
72
+ // 已通知过父级的窗口用 ref 记账(不参与渲染):窗口没变就不再通知,
73
+ // 掐断「通知 → 父级重算坐标轴 → chartOption 变 → effect 重跑 → 再通知」的自激回路
74
+ const notifiedZoomWindowRef = (0, _react.useRef)(null);
75
+ const emitZoomWindow = (0, _react.useCallback)(next => {
76
+ const prev = notifiedZoomWindowRef.current;
77
+ if (prev && prev.startIndex === next.startIndex && prev.endIndex === next.endIndex) {
78
+ return;
79
+ }
80
+ notifiedZoomWindowRef.current = next;
81
+ onDataZoomWindowChangedRef.current?.(next);
82
+ }, [onDataZoomWindowChangedRef]);
83
+
60
84
  // Cache the chart option with useMemo
61
85
  const chartOption = (0, _react.useMemo)(() => {
62
86
  const def_xAxis = (0, _cartesian.createDefaultCategoryXAxis)(xAxisData, chrome);
@@ -241,15 +265,15 @@ const ChartBar = props => {
241
265
  }
242
266
 
243
267
  // Sync the current visible window to the caller (on initial render and after restore), used to recalculate axes based on the displayed portion
244
- if (onDataZoomWindowChanged) {
268
+ if (hasOnDataZoomWindowChanged) {
245
269
  const zooms = chart.getOption()?.dataZoom;
246
270
  const zoom = zooms?.[0];
247
271
  const totalLen = xAxisData?.length ?? 0;
248
272
  if (typeof zoom?.start === "number" && typeof zoom?.end === "number") {
249
- onDataZoomWindowChanged((0, _cartesian.percentWindowToIndexWindow)(zoom.start, zoom.end, totalLen));
273
+ emitZoomWindow((0, _cartesian.percentWindowToIndexWindow)(zoom.start, zoom.end, totalLen));
250
274
  } else {
251
275
  // Without dataZoom (e.g. scrolling not enabled due to insufficient data) the window is the full range; sync once to avoid a stale old window
252
- onDataZoomWindowChanged({
276
+ emitZoomWindow({
253
277
  startIndex: 0,
254
278
  endIndex: Math.max(0, totalLen - 1)
255
279
  });
@@ -265,16 +289,19 @@ const ChartBar = props => {
265
289
  resizeObserverRef.current.observe(chartRef.current);
266
290
  }
267
291
 
268
- // Add click event
269
- if (onClick) {
270
- chartInstanceRef.current?.on("click", onClick);
292
+ // Add click event(注册与否看布尔量,实际调用取最新引用)
293
+ const handleClick = event => {
294
+ onClickRef.current?.(event);
295
+ };
296
+ if (hasOnClick) {
297
+ chartInstanceRef.current?.on("click", handleClick);
271
298
  }
272
299
 
273
300
  // Legend selection event
274
301
  const handleLegendSelectChanged = params => {
275
- onLegendSelectChanged?.(params.selected);
302
+ onLegendSelectChangedRef.current?.(params.selected);
276
303
  };
277
- if (onLegendSelectChanged) {
304
+ if (hasOnLegendSelectChanged) {
278
305
  chartInstanceRef.current?.on("legendselectchanged", handleLegendSelectChanged);
279
306
  }
280
307
 
@@ -283,15 +310,15 @@ const ChartBar = props => {
283
310
  const raw = params;
284
311
  const info = raw?.batch?.[0] ?? raw;
285
312
  if (typeof info?.startValue === "number" && typeof info?.endValue === "number") {
286
- onDataZoomWindowChanged?.({
313
+ emitZoomWindow({
287
314
  startIndex: Math.max(0, Math.round(info.startValue)),
288
315
  endIndex: Math.max(0, Math.round(info.endValue))
289
316
  });
290
317
  } else if (typeof info?.start === "number" && typeof info?.end === "number") {
291
- onDataZoomWindowChanged?.((0, _cartesian.percentWindowToIndexWindow)(info.start, info.end, xAxisData?.length ?? 0));
318
+ emitZoomWindow((0, _cartesian.percentWindowToIndexWindow)(info.start, info.end, xAxisData?.length ?? 0));
292
319
  }
293
320
  };
294
- if (onDataZoomWindowChanged) {
321
+ if (hasOnDataZoomWindowChanged) {
295
322
  chartInstanceRef.current?.on("datazoom", handleDataZoom);
296
323
  }
297
324
 
@@ -318,13 +345,13 @@ const ChartBar = props => {
318
345
  // Cleanup function
319
346
  return () => {
320
347
  window.removeEventListener("resize", handleResize);
321
- if (onClick) {
322
- chartInstanceRef.current?.off("click", onClick);
348
+ if (hasOnClick) {
349
+ chartInstanceRef.current?.off("click", handleClick);
323
350
  }
324
- if (onLegendSelectChanged) {
351
+ if (hasOnLegendSelectChanged) {
325
352
  chartInstanceRef.current?.off("legendselectchanged", handleLegendSelectChanged);
326
353
  }
327
- if (onDataZoomWindowChanged) {
354
+ if (hasOnDataZoomWindowChanged) {
328
355
  chartInstanceRef.current?.off("datazoom", handleDataZoom);
329
356
  }
330
357
  if (legendScrollRef.current) {
@@ -332,7 +359,7 @@ const ChartBar = props => {
332
359
  legendScrollRef.current = null;
333
360
  }
334
361
  };
335
- }, [chartOption, handleResize, onClick, onLegendSelectChanged, onDataZoomWindowChanged, xAxisData, enableLegendAutoScroll, legendVisibleCount, legendScrollInterval,
362
+ }, [chartOption, handleResize, onClickRef, onLegendSelectChangedRef, hasOnClick, hasOnLegendSelectChanged, hasOnDataZoomWindowChanged, emitZoomWindow, xAxisData, enableLegendAutoScroll, legendVisibleCount, legendScrollInterval,
336
363
  // 图例是否接管滚轮由它推导,漏了会拿到过期的滚动配置
337
364
  normalizedScroll, containerReady]);
338
365
 
@@ -351,10 +378,10 @@ const ChartBar = props => {
351
378
  startIndex: normalizedScroll.startIndex,
352
379
  autoPlay: normalizedScroll.autoScroll,
353
380
  enableWheelScroll,
354
- onWindowChange: onDataZoomWindowChanged ? (startPercent, endPercent) => onDataZoomWindowChanged((0, _cartesian.percentWindowToIndexWindow)(startPercent, endPercent, xAxisData.length)) : undefined
381
+ onWindowChange: hasOnDataZoomWindowChanged ? (startPercent, endPercent) => emitZoomWindow((0, _cartesian.percentWindowToIndexWindow)(startPercent, endPercent, xAxisData.length)) : undefined
355
382
  });
356
383
  return dispose;
357
- }, [dataZoom, normalizedScroll, sliderDataZoom, xAxisData, onDataZoomWindowChanged]);
384
+ }, [dataZoom, normalizedScroll, sliderDataZoom, xAxisData, hasOnDataZoomWindowChanged, emitZoomWindow]);
358
385
 
359
386
  // Clean up resources when the component unmounts
360
387
  (0, _react.useEffect)(() => {
@@ -257,6 +257,12 @@ const Bubble = props => {
257
257
  }, [chartOption, data]);
258
258
  const onChartRef = (0, _useLatestRef.useLatestRef)(onChart);
259
259
 
260
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
261
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
262
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
263
+ const onClickRef = (0, _useLatestRef.useLatestRef)(onClick);
264
+ const hasOnClick = !!onClick;
265
+
260
266
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
261
267
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
262
268
  // Initialize the chart
@@ -282,18 +288,21 @@ const Bubble = props => {
282
288
  window.addEventListener("resize", handleResize);
283
289
 
284
290
  // Add click event
285
- if (onClick) {
286
- chartInstanceRef.current?.on("click", onClick);
291
+ const handleClick = event => {
292
+ onClickRef.current?.(event);
293
+ };
294
+ if (hasOnClick) {
295
+ chartInstanceRef.current?.on("click", handleClick);
287
296
  }
288
297
 
289
298
  // Cleanup function
290
299
  return () => {
291
300
  window.removeEventListener("resize", handleResize);
292
- if (onClick) {
293
- chartInstanceRef.current?.off("click", onClick);
301
+ if (hasOnClick) {
302
+ chartInstanceRef.current?.off("click", handleClick);
294
303
  }
295
304
  };
296
- }, [chartOption, containerSize.width, containerSize.height, handleResize, onChartRef, onClick]);
305
+ }, [chartOption, containerSize.width, containerSize.height, handleResize, onChartRef, onClickRef, hasOnClick]);
297
306
 
298
307
  // Dispose the chart instance when the component unmounts
299
308
  (0, _react.useEffect)(() => {
@@ -194,6 +194,12 @@ const Heatmap = props => {
194
194
  }, []);
195
195
  const onChartRef = (0, _useLatestRef.useLatestRef)(onChart);
196
196
 
197
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
198
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
199
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
200
+ const onClickRef = (0, _useLatestRef.useLatestRef)(onClick);
201
+ const hasOnClick = !!onClick;
202
+
197
203
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
198
204
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
199
205
  // Initialize the chart
@@ -222,18 +228,21 @@ const Heatmap = props => {
222
228
  }
223
229
 
224
230
  // Add click event
225
- if (onClick) {
226
- chartInstanceRef.current?.on("click", onClick);
231
+ const handleClick = event => {
232
+ onClickRef.current?.(event);
233
+ };
234
+ if (hasOnClick) {
235
+ chartInstanceRef.current?.on("click", handleClick);
227
236
  }
228
237
 
229
238
  // Cleanup function
230
239
  return () => {
231
240
  window.removeEventListener("resize", handleResize);
232
- if (onClick) {
233
- chartInstanceRef.current?.off("click", onClick);
241
+ if (hasOnClick) {
242
+ chartInstanceRef.current?.off("click", handleClick);
234
243
  }
235
244
  };
236
- }, [chartOption, handleResize, onChartRef, onClick, containerReady]);
245
+ }, [chartOption, handleResize, onChartRef, onClickRef, hasOnClick, containerReady]);
237
246
 
238
247
  // Clean up resources when the component unmounts
239
248
  (0, _react.useEffect)(() => {
@@ -13,6 +13,7 @@ var _autoSmooth = require("../_utils/autoSmooth");
13
13
  var _cartesian = require("../_utils/cartesian");
14
14
  var _chartTheme = require("../_utils/chartTheme");
15
15
  var _useIsDark = _interopRequireDefault(require("../../../hooks/useIsDark"));
16
+ var _useLatestRef = _interopRequireDefault(require("../../../hooks/useLatestRef"));
16
17
  var _jsxRuntime = require("react/jsx-runtime");
17
18
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
18
19
  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); }
@@ -57,6 +58,29 @@ const ChartLine = props => {
57
58
  const isDark = (0, _useIsDark.default)();
58
59
  const chrome = (0, _react.useMemo)(() => (0, _chartTheme.resolveChartChrome)(isDark), [isDark]);
59
60
 
61
+ // 回调 prop 一律走 useLatestRef,不进依赖数组:消费方传内联箭头(React 里最常见的
62
+ // 写法)时每次渲染都是新引用,进依赖数组会让「init + setOption(notMerge)」整段重跑,
63
+ // 还会把当前可视窗口再同步一次给父级 —— 父级据此改 state 就是死循环。
64
+ // 但「有没有传回调」决定要不要注册监听,所以单独拆成布尔量进依赖数组,
65
+ // 这样「从无到有传入回调」仍会重新注册,不会漏。
66
+ const onClickRef = (0, _useLatestRef.default)(onClick);
67
+ const onLegendSelectChangedRef = (0, _useLatestRef.default)(onLegendSelectChanged);
68
+ const onDataZoomWindowChangedRef = (0, _useLatestRef.default)(onDataZoomWindowChanged);
69
+ const hasOnClick = !!onClick;
70
+ const hasOnLegendSelectChanged = !!onLegendSelectChanged;
71
+ const hasOnDataZoomWindowChanged = !!onDataZoomWindowChanged;
72
+ // 已通知过父级的窗口用 ref 记账(不参与渲染):窗口没变就不再通知,
73
+ // 掐断「通知 → 父级重算坐标轴 → chartOption 变 → effect 重跑 → 再通知」的自激回路
74
+ const notifiedZoomWindowRef = (0, _react.useRef)(null);
75
+ const emitZoomWindow = (0, _react.useCallback)(next => {
76
+ const prev = notifiedZoomWindowRef.current;
77
+ if (prev && prev.startIndex === next.startIndex && prev.endIndex === next.endIndex) {
78
+ return;
79
+ }
80
+ notifiedZoomWindowRef.current = next;
81
+ onDataZoomWindowChangedRef.current?.(next);
82
+ }, [onDataZoomWindowChangedRef]);
83
+
60
84
  // Cache the chart option with useMemo
61
85
  const chartOption = (0, _react.useMemo)(() => {
62
86
  const def_xAxis = (0, _cartesian.createDefaultCategoryXAxis)(xAxisData, chrome);
@@ -238,15 +262,15 @@ const ChartLine = props => {
238
262
  }
239
263
 
240
264
  // Sync the current visible window to the caller (on initial render and after restore), used to recalculate axes based on the displayed portion
241
- if (onDataZoomWindowChanged) {
265
+ if (hasOnDataZoomWindowChanged) {
242
266
  const zooms = chart.getOption()?.dataZoom;
243
267
  const zoom = zooms?.[0];
244
268
  const totalLen = xAxisData?.length ?? 0;
245
269
  if (typeof zoom?.start === "number" && typeof zoom?.end === "number") {
246
- onDataZoomWindowChanged((0, _cartesian.percentWindowToIndexWindow)(zoom.start, zoom.end, totalLen));
270
+ emitZoomWindow((0, _cartesian.percentWindowToIndexWindow)(zoom.start, zoom.end, totalLen));
247
271
  } else {
248
272
  // Without dataZoom (e.g. scrolling not enabled due to insufficient data) the window is the full range; sync once to avoid a stale old window
249
- onDataZoomWindowChanged({
273
+ emitZoomWindow({
250
274
  startIndex: 0,
251
275
  endIndex: Math.max(0, totalLen - 1)
252
276
  });
@@ -262,16 +286,19 @@ const ChartLine = props => {
262
286
  resizeObserverRef.current.observe(chartRef.current);
263
287
  }
264
288
 
265
- // Add click event
266
- if (onClick) {
267
- chartInstanceRef.current?.on("click", onClick);
289
+ // Add click event(注册与否看布尔量,实际调用取最新引用)
290
+ const handleClick = event => {
291
+ onClickRef.current?.(event);
292
+ };
293
+ if (hasOnClick) {
294
+ chartInstanceRef.current?.on("click", handleClick);
268
295
  }
269
296
 
270
297
  // Legend selection event
271
298
  const handleLegendSelectChanged = params => {
272
- onLegendSelectChanged?.(params.selected);
299
+ onLegendSelectChangedRef.current?.(params.selected);
273
300
  };
274
- if (onLegendSelectChanged) {
301
+ if (hasOnLegendSelectChanged) {
275
302
  chartInstanceRef.current?.on("legendselectchanged", handleLegendSelectChanged);
276
303
  }
277
304
 
@@ -280,15 +307,15 @@ const ChartLine = props => {
280
307
  const raw = params;
281
308
  const info = raw?.batch?.[0] ?? raw;
282
309
  if (typeof info?.startValue === "number" && typeof info?.endValue === "number") {
283
- onDataZoomWindowChanged?.({
310
+ emitZoomWindow({
284
311
  startIndex: Math.max(0, Math.round(info.startValue)),
285
312
  endIndex: Math.max(0, Math.round(info.endValue))
286
313
  });
287
314
  } else if (typeof info?.start === "number" && typeof info?.end === "number") {
288
- onDataZoomWindowChanged?.((0, _cartesian.percentWindowToIndexWindow)(info.start, info.end, xAxisData?.length ?? 0));
315
+ emitZoomWindow((0, _cartesian.percentWindowToIndexWindow)(info.start, info.end, xAxisData?.length ?? 0));
289
316
  }
290
317
  };
291
- if (onDataZoomWindowChanged) {
318
+ if (hasOnDataZoomWindowChanged) {
292
319
  chartInstanceRef.current?.on("datazoom", handleDataZoom);
293
320
  }
294
321
 
@@ -315,13 +342,13 @@ const ChartLine = props => {
315
342
  // Cleanup function
316
343
  return () => {
317
344
  window.removeEventListener("resize", handleResize);
318
- if (onClick) {
319
- chartInstanceRef.current?.off("click", onClick);
345
+ if (hasOnClick) {
346
+ chartInstanceRef.current?.off("click", handleClick);
320
347
  }
321
- if (onLegendSelectChanged) {
348
+ if (hasOnLegendSelectChanged) {
322
349
  chartInstanceRef.current?.off("legendselectchanged", handleLegendSelectChanged);
323
350
  }
324
- if (onDataZoomWindowChanged) {
351
+ if (hasOnDataZoomWindowChanged) {
325
352
  chartInstanceRef.current?.off("datazoom", handleDataZoom);
326
353
  }
327
354
  if (legendScrollRef.current) {
@@ -329,7 +356,7 @@ const ChartLine = props => {
329
356
  legendScrollRef.current = null;
330
357
  }
331
358
  };
332
- }, [chartOption, handleResize, onClick, onLegendSelectChanged, onDataZoomWindowChanged, xAxisData, enableLegendAutoScroll, legendVisibleCount, legendScrollInterval,
359
+ }, [chartOption, handleResize, onClickRef, onLegendSelectChangedRef, hasOnClick, hasOnLegendSelectChanged, hasOnDataZoomWindowChanged, emitZoomWindow, xAxisData, enableLegendAutoScroll, legendVisibleCount, legendScrollInterval,
333
360
  // 图例是否接管滚轮由它推导,漏了会拿到过期的滚动配置
334
361
  normalizedScroll, containerReady]);
335
362
 
@@ -348,10 +375,10 @@ const ChartLine = props => {
348
375
  startIndex: normalizedScroll.startIndex,
349
376
  autoPlay: normalizedScroll.autoScroll,
350
377
  enableWheelScroll,
351
- onWindowChange: onDataZoomWindowChanged ? (startPercent, endPercent) => onDataZoomWindowChanged((0, _cartesian.percentWindowToIndexWindow)(startPercent, endPercent, xAxisData.length)) : undefined
378
+ onWindowChange: hasOnDataZoomWindowChanged ? (startPercent, endPercent) => emitZoomWindow((0, _cartesian.percentWindowToIndexWindow)(startPercent, endPercent, xAxisData.length)) : undefined
352
379
  });
353
380
  return dispose;
354
- }, [dataZoom, normalizedScroll, sliderDataZoom, xAxisData, onDataZoomWindowChanged]);
381
+ }, [dataZoom, normalizedScroll, sliderDataZoom, xAxisData, hasOnDataZoomWindowChanged, emitZoomWindow]);
355
382
 
356
383
  // Clean up resources when the component unmounts
357
384
  (0, _react.useEffect)(() => {