@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
@@ -1,5 +1,6 @@
1
- import { useEffect } from "react";
1
+ import { useCallback, useEffect } from "react";
2
2
  import ChainGraphServices from "../ChainGraphServices";
3
+ import { useLatestRef } from "../../../hooks/useLatestRef";
3
4
 
4
5
  interface UseChainGraphLayoutProps {
5
6
  graph: ChainGraphServices | null;
@@ -11,9 +12,21 @@ interface UseChainGraphLayoutProps {
11
12
  export function useChainGraphLayout(props: UseChainGraphLayoutProps) {
12
13
  const { graph, octopus, rootLevel, getImage } = props;
13
14
 
15
+ // getImage 只是「布局完成后把截图 dataURL 交出去」的出口,不是布局的输入。
16
+ // 它进依赖数组会让消费方每传一次内联箭头就重新 changeLayout 一次 —— 整张图重排;
17
+ // 消费方再把截图存进 state,就变成「重排 → 出图 → setState → 新引用 → 再重排」的死循环。
18
+ // 用引用恒定的转发函数注册,调用时再取最新的 getImage。
19
+ const getImageRef = useLatestRef(getImage);
20
+ const emitImage = useCallback(
21
+ (img: string) => {
22
+ getImageRef.current?.(img);
23
+ },
24
+ [getImageRef],
25
+ );
26
+
14
27
  useEffect(() => {
15
28
  if (graph && octopus !== undefined) {
16
- graph.changeLayout(octopus, rootLevel, getImage);
29
+ graph.changeLayout(octopus, rootLevel, emitImage);
17
30
  }
18
- }, [graph, octopus, rootLevel, getImage]);
31
+ }, [graph, octopus, rootLevel, emitImage]);
19
32
  }
@@ -24,6 +24,7 @@ import {
24
24
  } from "../_utils/cartesian";
25
25
  import { resolveChartChrome } from "../_utils/chartTheme";
26
26
  import useIsDark from "../../../hooks/useIsDark";
27
+ import useLatestRef from "../../../hooks/useLatestRef";
27
28
 
28
29
  export interface ChartBarProps extends ChartCommonProps {
29
30
  chartTitle?: string;
@@ -89,6 +90,36 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
89
90
  const isDark = useIsDark();
90
91
  const chrome = useMemo(() => resolveChartChrome(isDark), [isDark]);
91
92
 
93
+ // 回调 prop 一律走 useLatestRef,不进依赖数组:消费方传内联箭头(React 里最常见的
94
+ // 写法)时每次渲染都是新引用,进依赖数组会让「init + setOption(notMerge)」整段重跑,
95
+ // 还会把当前可视窗口再同步一次给父级 —— 父级据此改 state 就是死循环。
96
+ // 但「有没有传回调」决定要不要注册监听,所以单独拆成布尔量进依赖数组,
97
+ // 这样「从无到有传入回调」仍会重新注册,不会漏。
98
+ const onClickRef = useLatestRef(onClick);
99
+ const onLegendSelectChangedRef = useLatestRef(onLegendSelectChanged);
100
+ const onDataZoomWindowChangedRef = useLatestRef(onDataZoomWindowChanged);
101
+ const hasOnClick = !!onClick;
102
+ const hasOnLegendSelectChanged = !!onLegendSelectChanged;
103
+ const hasOnDataZoomWindowChanged = !!onDataZoomWindowChanged;
104
+ // 已通知过父级的窗口用 ref 记账(不参与渲染):窗口没变就不再通知,
105
+ // 掐断「通知 → 父级重算坐标轴 → chartOption 变 → effect 重跑 → 再通知」的自激回路
106
+ const notifiedZoomWindowRef = useRef<DataZoomIndexWindow | null>(null);
107
+ const emitZoomWindow = useCallback(
108
+ (next: DataZoomIndexWindow) => {
109
+ const prev = notifiedZoomWindowRef.current;
110
+ if (
111
+ prev &&
112
+ prev.startIndex === next.startIndex &&
113
+ prev.endIndex === next.endIndex
114
+ ) {
115
+ return;
116
+ }
117
+ notifiedZoomWindowRef.current = next;
118
+ onDataZoomWindowChangedRef.current?.(next);
119
+ },
120
+ [onDataZoomWindowChangedRef],
121
+ );
122
+
92
123
  // Cache the chart option with useMemo
93
124
  const chartOption = useMemo(() => {
94
125
  const def_xAxis = createDefaultCategoryXAxis(xAxisData, chrome);
@@ -328,18 +359,18 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
328
359
  }
329
360
 
330
361
  // Sync the current visible window to the caller (on initial render and after restore), used to recalculate axes based on the displayed portion
331
- if (onDataZoomWindowChanged) {
362
+ if (hasOnDataZoomWindowChanged) {
332
363
  const zooms = (chart.getOption() as ChartsOption | undefined)
333
364
  ?.dataZoom as Array<{ start?: number; end?: number }> | undefined;
334
365
  const zoom = zooms?.[0];
335
366
  const totalLen = xAxisData?.length ?? 0;
336
367
  if (typeof zoom?.start === "number" && typeof zoom?.end === "number") {
337
- onDataZoomWindowChanged(
368
+ emitZoomWindow(
338
369
  percentWindowToIndexWindow(zoom.start, zoom.end, totalLen),
339
370
  );
340
371
  } else {
341
372
  // 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
342
- onDataZoomWindowChanged({
373
+ emitZoomWindow({
343
374
  startIndex: 0,
344
375
  endIndex: Math.max(0, totalLen - 1),
345
376
  });
@@ -355,18 +386,21 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
355
386
  resizeObserverRef.current.observe(chartRef.current);
356
387
  }
357
388
 
358
- // Add click event
359
- if (onClick) {
360
- chartInstanceRef.current?.on("click", onClick);
389
+ // Add click event(注册与否看布尔量,实际调用取最新引用)
390
+ const handleClick = (event: echarts.ECElementEvent) => {
391
+ onClickRef.current?.(event);
392
+ };
393
+ if (hasOnClick) {
394
+ chartInstanceRef.current?.on("click", handleClick);
361
395
  }
362
396
 
363
397
  // Legend selection event
364
398
  const handleLegendSelectChanged = (params: unknown) => {
365
- onLegendSelectChanged?.(
399
+ onLegendSelectChangedRef.current?.(
366
400
  (params as { selected: Record<string, boolean> }).selected,
367
401
  );
368
402
  };
369
- if (onLegendSelectChanged) {
403
+ if (hasOnLegendSelectChanged) {
370
404
  chartInstanceRef.current?.on(
371
405
  "legendselectchanged",
372
406
  handleLegendSelectChanged,
@@ -392,7 +426,7 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
392
426
  typeof info?.startValue === "number" &&
393
427
  typeof info?.endValue === "number"
394
428
  ) {
395
- onDataZoomWindowChanged?.({
429
+ emitZoomWindow({
396
430
  startIndex: Math.max(0, Math.round(info.startValue)),
397
431
  endIndex: Math.max(0, Math.round(info.endValue)),
398
432
  });
@@ -400,7 +434,7 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
400
434
  typeof info?.start === "number" &&
401
435
  typeof info?.end === "number"
402
436
  ) {
403
- onDataZoomWindowChanged?.(
437
+ emitZoomWindow(
404
438
  percentWindowToIndexWindow(
405
439
  info.start,
406
440
  info.end,
@@ -409,7 +443,7 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
409
443
  );
410
444
  }
411
445
  };
412
- if (onDataZoomWindowChanged) {
446
+ if (hasOnDataZoomWindowChanged) {
413
447
  chartInstanceRef.current?.on("datazoom", handleDataZoom);
414
448
  }
415
449
 
@@ -445,18 +479,18 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
445
479
  return () => {
446
480
  window.removeEventListener("resize", handleResize);
447
481
 
448
- if (onClick) {
449
- chartInstanceRef.current?.off("click", onClick);
482
+ if (hasOnClick) {
483
+ chartInstanceRef.current?.off("click", handleClick);
450
484
  }
451
485
 
452
- if (onLegendSelectChanged) {
486
+ if (hasOnLegendSelectChanged) {
453
487
  chartInstanceRef.current?.off(
454
488
  "legendselectchanged",
455
489
  handleLegendSelectChanged,
456
490
  );
457
491
  }
458
492
 
459
- if (onDataZoomWindowChanged) {
493
+ if (hasOnDataZoomWindowChanged) {
460
494
  chartInstanceRef.current?.off("datazoom", handleDataZoom);
461
495
  }
462
496
 
@@ -468,9 +502,12 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
468
502
  }, [
469
503
  chartOption,
470
504
  handleResize,
471
- onClick,
472
- onLegendSelectChanged,
473
- onDataZoomWindowChanged,
505
+ onClickRef,
506
+ onLegendSelectChangedRef,
507
+ hasOnClick,
508
+ hasOnLegendSelectChanged,
509
+ hasOnDataZoomWindowChanged,
510
+ emitZoomWindow,
474
511
  xAxisData,
475
512
  enableLegendAutoScroll,
476
513
  legendVisibleCount,
@@ -507,9 +544,9 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
507
544
  startIndex: normalizedScroll.startIndex,
508
545
  autoPlay: normalizedScroll.autoScroll,
509
546
  enableWheelScroll,
510
- onWindowChange: onDataZoomWindowChanged
547
+ onWindowChange: hasOnDataZoomWindowChanged
511
548
  ? (startPercent, endPercent) =>
512
- onDataZoomWindowChanged(
549
+ emitZoomWindow(
513
550
  percentWindowToIndexWindow(
514
551
  startPercent,
515
552
  endPercent,
@@ -525,7 +562,8 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
525
562
  normalizedScroll,
526
563
  sliderDataZoom,
527
564
  xAxisData,
528
- onDataZoomWindowChanged,
565
+ hasOnDataZoomWindowChanged,
566
+ emitZoomWindow,
529
567
  ]);
530
568
 
531
569
  // Clean up resources when the component unmounts
@@ -373,6 +373,12 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
373
373
 
374
374
  const onChartRef = useLatestRef(onChart);
375
375
 
376
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
377
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
378
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
379
+ const onClickRef = useLatestRef(onClick);
380
+ const hasOnClick = !!onClick;
381
+
376
382
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
377
383
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
378
384
  // Initialize the chart
@@ -398,16 +404,19 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
398
404
  window.addEventListener("resize", handleResize);
399
405
 
400
406
  // Add click event
401
- if (onClick) {
402
- chartInstanceRef.current?.on("click", onClick);
407
+ const handleClick = (event: echarts.ECElementEvent) => {
408
+ onClickRef.current?.(event);
409
+ };
410
+ if (hasOnClick) {
411
+ chartInstanceRef.current?.on("click", handleClick);
403
412
  }
404
413
 
405
414
  // Cleanup function
406
415
  return () => {
407
416
  window.removeEventListener("resize", handleResize);
408
417
 
409
- if (onClick) {
410
- chartInstanceRef.current?.off("click", onClick);
418
+ if (hasOnClick) {
419
+ chartInstanceRef.current?.off("click", handleClick);
411
420
  }
412
421
  };
413
422
  }, [
@@ -416,7 +425,8 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
416
425
  containerSize.height,
417
426
  handleResize,
418
427
  onChartRef,
419
- onClick,
428
+ onClickRef,
429
+ hasOnClick,
420
430
  ]);
421
431
 
422
432
  // Dispose the chart instance when the component unmounts
@@ -269,6 +269,12 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
269
269
 
270
270
  const onChartRef = useLatestRef(onChart);
271
271
 
272
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
273
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
274
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
275
+ const onClickRef = useLatestRef(onClick);
276
+ const hasOnClick = !!onClick;
277
+
272
278
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
273
279
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
274
280
  // Initialize the chart
@@ -297,19 +303,27 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
297
303
  }
298
304
 
299
305
  // Add click event
300
- if (onClick) {
301
- chartInstanceRef.current?.on("click", onClick);
306
+ const handleClick = (event: echarts.ECElementEvent) => {
307
+ onClickRef.current?.(event);
308
+ };
309
+ if (hasOnClick) {
310
+ chartInstanceRef.current?.on("click", handleClick);
302
311
  }
303
312
 
304
313
  // Cleanup function
305
314
  return () => {
306
315
  window.removeEventListener("resize", handleResize);
307
316
 
308
- if (onClick) {
309
- chartInstanceRef.current?.off("click", onClick);
317
+ if (hasOnClick) {
318
+ chartInstanceRef.current?.off("click", handleClick);
310
319
  }
311
320
  };
312
- }, [chartOption, handleResize, onChartRef, onClick,
321
+ }, [
322
+ chartOption,
323
+ handleResize,
324
+ onChartRef,
325
+ onClickRef,
326
+ hasOnClick,
313
327
  containerReady,
314
328
  ]);
315
329
 
@@ -24,6 +24,7 @@ import {
24
24
  } from "../_utils/cartesian";
25
25
  import { resolveChartChrome } from "../_utils/chartTheme";
26
26
  import useIsDark from "../../../hooks/useIsDark";
27
+ import useLatestRef from "../../../hooks/useLatestRef";
27
28
 
28
29
  export interface ChartLineProps extends ChartCommonProps {
29
30
  chartTitle?: string;
@@ -90,6 +91,36 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
90
91
  const isDark = useIsDark();
91
92
  const chrome = useMemo(() => resolveChartChrome(isDark), [isDark]);
92
93
 
94
+ // 回调 prop 一律走 useLatestRef,不进依赖数组:消费方传内联箭头(React 里最常见的
95
+ // 写法)时每次渲染都是新引用,进依赖数组会让「init + setOption(notMerge)」整段重跑,
96
+ // 还会把当前可视窗口再同步一次给父级 —— 父级据此改 state 就是死循环。
97
+ // 但「有没有传回调」决定要不要注册监听,所以单独拆成布尔量进依赖数组,
98
+ // 这样「从无到有传入回调」仍会重新注册,不会漏。
99
+ const onClickRef = useLatestRef(onClick);
100
+ const onLegendSelectChangedRef = useLatestRef(onLegendSelectChanged);
101
+ const onDataZoomWindowChangedRef = useLatestRef(onDataZoomWindowChanged);
102
+ const hasOnClick = !!onClick;
103
+ const hasOnLegendSelectChanged = !!onLegendSelectChanged;
104
+ const hasOnDataZoomWindowChanged = !!onDataZoomWindowChanged;
105
+ // 已通知过父级的窗口用 ref 记账(不参与渲染):窗口没变就不再通知,
106
+ // 掐断「通知 → 父级重算坐标轴 → chartOption 变 → effect 重跑 → 再通知」的自激回路
107
+ const notifiedZoomWindowRef = useRef<DataZoomIndexWindow | null>(null);
108
+ const emitZoomWindow = useCallback(
109
+ (next: DataZoomIndexWindow) => {
110
+ const prev = notifiedZoomWindowRef.current;
111
+ if (
112
+ prev &&
113
+ prev.startIndex === next.startIndex &&
114
+ prev.endIndex === next.endIndex
115
+ ) {
116
+ return;
117
+ }
118
+ notifiedZoomWindowRef.current = next;
119
+ onDataZoomWindowChangedRef.current?.(next);
120
+ },
121
+ [onDataZoomWindowChangedRef],
122
+ );
123
+
93
124
  // Cache the chart option with useMemo
94
125
  const chartOption = useMemo(() => {
95
126
  const def_xAxis = createDefaultCategoryXAxis(xAxisData, chrome);
@@ -326,18 +357,18 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
326
357
  }
327
358
 
328
359
  // Sync the current visible window to the caller (on initial render and after restore), used to recalculate axes based on the displayed portion
329
- if (onDataZoomWindowChanged) {
360
+ if (hasOnDataZoomWindowChanged) {
330
361
  const zooms = (chart.getOption() as ChartsOption | undefined)
331
362
  ?.dataZoom as Array<{ start?: number; end?: number }> | undefined;
332
363
  const zoom = zooms?.[0];
333
364
  const totalLen = xAxisData?.length ?? 0;
334
365
  if (typeof zoom?.start === "number" && typeof zoom?.end === "number") {
335
- onDataZoomWindowChanged(
366
+ emitZoomWindow(
336
367
  percentWindowToIndexWindow(zoom.start, zoom.end, totalLen),
337
368
  );
338
369
  } else {
339
370
  // 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
340
- onDataZoomWindowChanged({
371
+ emitZoomWindow({
341
372
  startIndex: 0,
342
373
  endIndex: Math.max(0, totalLen - 1),
343
374
  });
@@ -353,18 +384,21 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
353
384
  resizeObserverRef.current.observe(chartRef.current);
354
385
  }
355
386
 
356
- // Add click event
357
- if (onClick) {
358
- chartInstanceRef.current?.on("click", onClick);
387
+ // Add click event(注册与否看布尔量,实际调用取最新引用)
388
+ const handleClick = (event: echarts.ECElementEvent) => {
389
+ onClickRef.current?.(event);
390
+ };
391
+ if (hasOnClick) {
392
+ chartInstanceRef.current?.on("click", handleClick);
359
393
  }
360
394
 
361
395
  // Legend selection event
362
396
  const handleLegendSelectChanged = (params: unknown) => {
363
- onLegendSelectChanged?.(
397
+ onLegendSelectChangedRef.current?.(
364
398
  (params as { selected: Record<string, boolean> }).selected,
365
399
  );
366
400
  };
367
- if (onLegendSelectChanged) {
401
+ if (hasOnLegendSelectChanged) {
368
402
  chartInstanceRef.current?.on(
369
403
  "legendselectchanged",
370
404
  handleLegendSelectChanged,
@@ -390,7 +424,7 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
390
424
  typeof info?.startValue === "number" &&
391
425
  typeof info?.endValue === "number"
392
426
  ) {
393
- onDataZoomWindowChanged?.({
427
+ emitZoomWindow({
394
428
  startIndex: Math.max(0, Math.round(info.startValue)),
395
429
  endIndex: Math.max(0, Math.round(info.endValue)),
396
430
  });
@@ -398,7 +432,7 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
398
432
  typeof info?.start === "number" &&
399
433
  typeof info?.end === "number"
400
434
  ) {
401
- onDataZoomWindowChanged?.(
435
+ emitZoomWindow(
402
436
  percentWindowToIndexWindow(
403
437
  info.start,
404
438
  info.end,
@@ -407,7 +441,7 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
407
441
  );
408
442
  }
409
443
  };
410
- if (onDataZoomWindowChanged) {
444
+ if (hasOnDataZoomWindowChanged) {
411
445
  chartInstanceRef.current?.on("datazoom", handleDataZoom);
412
446
  }
413
447
 
@@ -443,18 +477,18 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
443
477
  return () => {
444
478
  window.removeEventListener("resize", handleResize);
445
479
 
446
- if (onClick) {
447
- chartInstanceRef.current?.off("click", onClick);
480
+ if (hasOnClick) {
481
+ chartInstanceRef.current?.off("click", handleClick);
448
482
  }
449
483
 
450
- if (onLegendSelectChanged) {
484
+ if (hasOnLegendSelectChanged) {
451
485
  chartInstanceRef.current?.off(
452
486
  "legendselectchanged",
453
487
  handleLegendSelectChanged,
454
488
  );
455
489
  }
456
490
 
457
- if (onDataZoomWindowChanged) {
491
+ if (hasOnDataZoomWindowChanged) {
458
492
  chartInstanceRef.current?.off("datazoom", handleDataZoom);
459
493
  }
460
494
 
@@ -466,9 +500,12 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
466
500
  }, [
467
501
  chartOption,
468
502
  handleResize,
469
- onClick,
470
- onLegendSelectChanged,
471
- onDataZoomWindowChanged,
503
+ onClickRef,
504
+ onLegendSelectChangedRef,
505
+ hasOnClick,
506
+ hasOnLegendSelectChanged,
507
+ hasOnDataZoomWindowChanged,
508
+ emitZoomWindow,
472
509
  xAxisData,
473
510
  enableLegendAutoScroll,
474
511
  legendVisibleCount,
@@ -505,9 +542,9 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
505
542
  startIndex: normalizedScroll.startIndex,
506
543
  autoPlay: normalizedScroll.autoScroll,
507
544
  enableWheelScroll,
508
- onWindowChange: onDataZoomWindowChanged
545
+ onWindowChange: hasOnDataZoomWindowChanged
509
546
  ? (startPercent, endPercent) =>
510
- onDataZoomWindowChanged(
547
+ emitZoomWindow(
511
548
  percentWindowToIndexWindow(
512
549
  startPercent,
513
550
  endPercent,
@@ -523,7 +560,8 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
523
560
  normalizedScroll,
524
561
  sliderDataZoom,
525
562
  xAxisData,
526
- onDataZoomWindowChanged,
563
+ hasOnDataZoomWindowChanged,
564
+ emitZoomWindow,
527
565
  ]);
528
566
 
529
567
  // Clean up resources when the component unmounts
@@ -173,6 +173,12 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
173
173
 
174
174
  const onChartRef = useLatestRef(onChart);
175
175
 
176
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑会重建
177
+ // 3D 饼的 option 并重播动画。注册与否看布尔量(保证「从无到有传入」仍会注册),
178
+ // 实际调用取 ref 里的最新引用。
179
+ const onClickRef = useLatestRef(onClick);
180
+ const hasOnClick = !!onClick;
181
+
176
182
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
177
183
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
178
184
  useEffect(() => {
@@ -197,8 +203,11 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
197
203
 
198
204
  chart.on("mouseover", handleMouseOver);
199
205
  chart.on("globalout", handleGlobalOut);
200
- if (onClick) {
201
- chart.on("click", onClick);
206
+ const handleClick = (event: echarts.ECElementEvent) => {
207
+ onClickRef.current?.(event);
208
+ };
209
+ if (hasOnClick) {
210
+ chart.on("click", handleClick);
202
211
  }
203
212
 
204
213
  return () => {
@@ -206,8 +215,8 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
206
215
  if (chart) {
207
216
  chart.off("mouseover", handleMouseOver);
208
217
  chart.off("globalout", handleGlobalOut);
209
- if (onClick) {
210
- chart.off("click", onClick);
218
+ if (hasOnClick) {
219
+ chart.off("click", handleClick);
211
220
  }
212
221
  }
213
222
  };
@@ -217,7 +226,8 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
217
226
  handleMouseOver,
218
227
  handleGlobalOut,
219
228
  onChartRef,
220
- onClick,
229
+ onClickRef,
230
+ hasOnClick,
221
231
  containerReady,
222
232
  ]);
223
233
 
@@ -255,6 +255,12 @@ const ChartPie: ChartPieFC = (props) => {
255
255
 
256
256
  const onChartRef = useLatestRef(onChart);
257
257
 
258
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
259
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
260
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
261
+ const onClickRef = useLatestRef(onClick);
262
+ const hasOnClick = !!onClick;
263
+
258
264
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
259
265
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
260
266
  // Initialize the chart
@@ -283,8 +289,11 @@ const ChartPie: ChartPieFC = (props) => {
283
289
  }
284
290
 
285
291
  // Add click event
286
- if (onClick) {
287
- chartInstanceRef.current?.on("click", onClick);
292
+ const handleClick = (event: echarts.ECElementEvent) => {
293
+ onClickRef.current?.(event);
294
+ };
295
+ if (hasOnClick) {
296
+ chartInstanceRef.current?.on("click", handleClick);
288
297
  }
289
298
 
290
299
  // Legend auto-scroll
@@ -307,8 +316,8 @@ const ChartPie: ChartPieFC = (props) => {
307
316
  return () => {
308
317
  window.removeEventListener("resize", handleResize);
309
318
 
310
- if (onClick) {
311
- chartInstanceRef.current?.off("click", onClick);
319
+ if (hasOnClick) {
320
+ chartInstanceRef.current?.off("click", handleClick);
312
321
  }
313
322
 
314
323
  // Clean up legend scrolling
@@ -321,7 +330,8 @@ const ChartPie: ChartPieFC = (props) => {
321
330
  chartOption,
322
331
  handleResize,
323
332
  onChartRef,
324
- onClick,
333
+ onClickRef,
334
+ hasOnClick,
325
335
  enableLegendAutoScroll,
326
336
  seriesData,
327
337
  legendVisibleCount,
@@ -3,6 +3,7 @@ import useContainerReady from "../_hooks/useContainerReady";
3
3
  import styles from "../index.module.scss";
4
4
  import { ChartCommonProps, ChartOptionType, ChartsOption } from "..";
5
5
  import * as echarts from "echarts";
6
+ import useLatestRef from "../../../hooks/useLatestRef";
6
7
 
7
8
  type SankeyNodeItemOption = echarts.SankeySeriesOption["data"];
8
9
  type SankeyEdgeItemOption = echarts.SankeySeriesOption["links"];
@@ -101,6 +102,13 @@ const Sankey: React.FC<ChartSankeyProps> = (props) => {
101
102
  return option;
102
103
  }, [seriesData, seriesLinks, series]);
103
104
 
105
+ // getImage 只是「出图后把 dataURL 交出去」的出口,既不决定图怎么画,
106
+ // 也不该决定要不要重画。它进依赖数组会让 effect 跟着重跑 → setOption(notMerge)
107
+ // → 图重绘 → 再触发一次 finished → 再回调;消费方把图存进 state 就是死循环。
108
+ // 而且 finished 监听只在首次 init 时注册,闭包里锁死的是首帧的 getImage,
109
+ // 后续换了引用永远调不到 —— 依赖数组写了它也没用,是条死依赖。走 ref 两个问题一起解决。
110
+ const getImageRef = useLatestRef(getImage);
111
+
104
112
  // Callback handling chart resize
105
113
  const handleResize = useCallback(() => {
106
114
  // A keep-alive tab losing focus fires the observer with a 0×0 box; resizing to that throws
@@ -123,7 +131,7 @@ const Sankey: React.FC<ChartSankeyProps> = (props) => {
123
131
 
124
132
  // Attach the finished event listener on first initialization
125
133
  chart.on("finished", () => {
126
- getImage?.(
134
+ getImageRef.current?.(
127
135
  chart!.getDataURL({
128
136
  type: "png",
129
137
  pixelRatio: 1,
@@ -149,9 +157,7 @@ const Sankey: React.FC<ChartSankeyProps> = (props) => {
149
157
  return () => {
150
158
  window.removeEventListener("resize", handleResize);
151
159
  };
152
- }, [chartOption, handleResize, getImage,
153
- containerReady,
154
- ]);
160
+ }, [chartOption, handleResize, getImageRef, containerReady]);
155
161
 
156
162
  // Clean up resources on unmount
157
163
  useEffect(() => {
@@ -8,6 +8,7 @@ import {
8
8
  SeriesData,
9
9
  } from "..";
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
 
@@ -41,6 +42,13 @@ const Tree: React.FC<ChartTreeProps> = (props) => {
41
42
  return option;
42
43
  }, [seriesData, series]);
43
44
 
45
+ // getImage 只是「出图后把 dataURL 交出去」的出口,既不决定图怎么画,
46
+ // 也不该决定要不要重画。它进依赖数组会让 effect 跟着重跑 → setOption(notMerge)
47
+ // → 图重绘 → 再触发一次 finished → 再回调;消费方把图存进 state 就是死循环。
48
+ // 而且 finished 监听只在首次 init 时注册,闭包里锁死的是首帧的 getImage,
49
+ // 后续换了引用永远调不到 —— 依赖数组写了它也没用,是条死依赖。走 ref 两个问题一起解决。
50
+ const getImageRef = useLatestRef(getImage);
51
+
44
52
  // Callback handling chart resize
45
53
  const handleResize = useCallback(() => {
46
54
  // A keep-alive tab losing focus fires the observer with a 0×0 box; resizing to that throws
@@ -63,7 +71,7 @@ const Tree: React.FC<ChartTreeProps> = (props) => {
63
71
 
64
72
  // Attach the finished event listener on first initialization
65
73
  chart.on("finished", () => {
66
- getImage?.(
74
+ getImageRef.current?.(
67
75
  chart!.getDataURL({
68
76
  type: "png",
69
77
  pixelRatio: 1,
@@ -89,9 +97,7 @@ const Tree: React.FC<ChartTreeProps> = (props) => {
89
97
  return () => {
90
98
  window.removeEventListener("resize", handleResize);
91
99
  };
92
- }, [chartOption, handleResize, getImage,
93
- containerReady,
94
- ]);
100
+ }, [chartOption, handleResize, getImageRef, containerReady]);
95
101
 
96
102
  // Clean up resources on unmount
97
103
  useEffect(() => {