@hsu-react/ui 2.5.4 → 2.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/es/components/ChainGraph/_hooks/useChainGraphData.js +29 -8
  2. package/es/components/ChainGraph/_hooks/useChainGraphLayout.js +14 -3
  3. package/es/components/Chart/Bar/index.js +55 -20
  4. package/es/components/Chart/Bubble/index.js +21 -6
  5. package/es/components/Chart/Common/index.js +6 -1
  6. package/es/components/Chart/Gauge/index.js +6 -1
  7. package/es/components/Chart/Heatmap/index.js +24 -7
  8. package/es/components/Chart/Line/index.js +55 -20
  9. package/es/components/Chart/Pie/Pie3D/index.js +30 -12
  10. package/es/components/Chart/Pie/index.js +24 -7
  11. package/es/components/Chart/Polar/index.js +9 -2
  12. package/es/components/Chart/Radar/index.js +6 -1
  13. package/es/components/Chart/Sankey/index.js +11 -2
  14. package/es/components/Chart/Tree/index.js +11 -2
  15. package/es/components/Select/_hooks/useSelectComposition.js +6 -2
  16. package/es/components/Table/_hooks/useAutoScrolling.js +16 -7
  17. package/es/hooks/useShallowStable.d.ts +32 -0
  18. package/es/hooks/useShallowStable.js +54 -0
  19. package/es/layout/Menu/_hooks/useOnlyLvOneMenu.js +9 -2
  20. package/lib/components/ChainGraph/_hooks/useChainGraphData.js +24 -7
  21. package/lib/components/ChainGraph/_hooks/useChainGraphLayout.js +12 -2
  22. package/lib/components/Chart/Bar/index.js +51 -19
  23. package/lib/components/Chart/Bubble/index.js +20 -6
  24. package/lib/components/Chart/Common/index.js +6 -1
  25. package/lib/components/Chart/Gauge/index.js +6 -1
  26. package/lib/components/Chart/Heatmap/index.js +23 -7
  27. package/lib/components/Chart/Line/index.js +51 -19
  28. package/lib/components/Chart/Pie/Pie3D/index.js +29 -12
  29. package/lib/components/Chart/Pie/index.js +23 -7
  30. package/lib/components/Chart/Polar/index.js +9 -2
  31. package/lib/components/Chart/Radar/index.js +6 -1
  32. package/lib/components/Chart/Sankey/index.js +10 -2
  33. package/lib/components/Chart/Tree/index.js +10 -2
  34. package/lib/components/Select/_hooks/useSelectComposition.js +6 -2
  35. package/lib/components/Table/_hooks/useAutoScrolling.js +16 -7
  36. package/lib/hooks/useShallowStable.d.ts +32 -0
  37. package/lib/hooks/useShallowStable.js +59 -0
  38. package/lib/layout/Menu/_hooks/useOnlyLvOneMenu.js +8 -2
  39. package/package.json +1 -1
  40. package/src/__tests__/callbackDepsGuard.test.ts +72 -0
  41. package/src/__tests__/depsScan.ts +158 -0
  42. package/src/__tests__/freshObjectDepsGuard.test.ts +144 -0
  43. package/src/components/ChainGraph/_hooks/callbacks.test.tsx +155 -0
  44. package/src/components/ChainGraph/_hooks/useChainGraphData.ts +38 -10
  45. package/src/components/ChainGraph/_hooks/useChainGraphLayout.ts +16 -3
  46. package/src/components/Chart/Bar/index.tsx +65 -22
  47. package/src/components/Chart/Bubble/index.tsx +21 -6
  48. package/src/components/Chart/Common/index.tsx +6 -1
  49. package/src/components/Chart/Gauge/index.tsx +6 -1
  50. package/src/components/Chart/Heatmap/index.tsx +39 -17
  51. package/src/components/Chart/Line/index.tsx +65 -22
  52. package/src/components/Chart/Pie/Pie3D/index.tsx +27 -8
  53. package/src/components/Chart/Pie/index.tsx +25 -7
  54. package/src/components/Chart/Polar/index.tsx +10 -2
  55. package/src/components/Chart/Radar/index.tsx +6 -1
  56. package/src/components/Chart/Sankey/index.tsx +10 -4
  57. package/src/components/Chart/Tree/index.tsx +10 -4
  58. package/src/components/Chart/callbacks.test.tsx +315 -0
  59. package/src/components/Chart/optionMemo.test.tsx +232 -0
  60. package/src/components/Select/_hooks/useSelectComposition.test.ts +47 -0
  61. package/src/components/Select/_hooks/useSelectComposition.ts +6 -2
  62. package/src/components/Table/_hooks/useAutoScrolling.test.tsx +95 -0
  63. package/src/components/Table/_hooks/useAutoScrolling.ts +16 -7
  64. package/src/hooks/useShallowStable.ts +65 -0
  65. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.test.tsx +56 -0
  66. package/src/layout/Menu/_hooks/useOnlyLvOneMenu.ts +8 -2
@@ -24,6 +24,8 @@ 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";
28
+ import useShallowStable from "../../../hooks/useShallowStable";
27
29
 
28
30
  export interface ChartBarProps extends ChartCommonProps {
29
31
  chartTitle?: string;
@@ -69,8 +71,12 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
69
71
  enableLegendAutoScroll = false,
70
72
  legendVisibleCount = 8,
71
73
  legendScrollInterval = 1500,
72
- ...coreOption
74
+ ...restOption
73
75
  } = props;
76
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
77
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
78
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
79
+ const coreOption = useShallowStable(restOption);
74
80
  const chartRef = useRef<HTMLDivElement>(null);
75
81
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
76
82
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -89,6 +95,36 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
89
95
  const isDark = useIsDark();
90
96
  const chrome = useMemo(() => resolveChartChrome(isDark), [isDark]);
91
97
 
98
+ // 回调 prop 一律走 useLatestRef,不进依赖数组:消费方传内联箭头(React 里最常见的
99
+ // 写法)时每次渲染都是新引用,进依赖数组会让「init + setOption(notMerge)」整段重跑,
100
+ // 还会把当前可视窗口再同步一次给父级 —— 父级据此改 state 就是死循环。
101
+ // 但「有没有传回调」决定要不要注册监听,所以单独拆成布尔量进依赖数组,
102
+ // 这样「从无到有传入回调」仍会重新注册,不会漏。
103
+ const onClickRef = useLatestRef(onClick);
104
+ const onLegendSelectChangedRef = useLatestRef(onLegendSelectChanged);
105
+ const onDataZoomWindowChangedRef = useLatestRef(onDataZoomWindowChanged);
106
+ const hasOnClick = !!onClick;
107
+ const hasOnLegendSelectChanged = !!onLegendSelectChanged;
108
+ const hasOnDataZoomWindowChanged = !!onDataZoomWindowChanged;
109
+ // 已通知过父级的窗口用 ref 记账(不参与渲染):窗口没变就不再通知,
110
+ // 掐断「通知 → 父级重算坐标轴 → chartOption 变 → effect 重跑 → 再通知」的自激回路
111
+ const notifiedZoomWindowRef = useRef<DataZoomIndexWindow | null>(null);
112
+ const emitZoomWindow = useCallback(
113
+ (next: DataZoomIndexWindow) => {
114
+ const prev = notifiedZoomWindowRef.current;
115
+ if (
116
+ prev &&
117
+ prev.startIndex === next.startIndex &&
118
+ prev.endIndex === next.endIndex
119
+ ) {
120
+ return;
121
+ }
122
+ notifiedZoomWindowRef.current = next;
123
+ onDataZoomWindowChangedRef.current?.(next);
124
+ },
125
+ [onDataZoomWindowChangedRef],
126
+ );
127
+
92
128
  // Cache the chart option with useMemo
93
129
  const chartOption = useMemo(() => {
94
130
  const def_xAxis = createDefaultCategoryXAxis(xAxisData, chrome);
@@ -328,18 +364,18 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
328
364
  }
329
365
 
330
366
  // 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) {
367
+ if (hasOnDataZoomWindowChanged) {
332
368
  const zooms = (chart.getOption() as ChartsOption | undefined)
333
369
  ?.dataZoom as Array<{ start?: number; end?: number }> | undefined;
334
370
  const zoom = zooms?.[0];
335
371
  const totalLen = xAxisData?.length ?? 0;
336
372
  if (typeof zoom?.start === "number" && typeof zoom?.end === "number") {
337
- onDataZoomWindowChanged(
373
+ emitZoomWindow(
338
374
  percentWindowToIndexWindow(zoom.start, zoom.end, totalLen),
339
375
  );
340
376
  } else {
341
377
  // 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({
378
+ emitZoomWindow({
343
379
  startIndex: 0,
344
380
  endIndex: Math.max(0, totalLen - 1),
345
381
  });
@@ -355,18 +391,21 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
355
391
  resizeObserverRef.current.observe(chartRef.current);
356
392
  }
357
393
 
358
- // Add click event
359
- if (onClick) {
360
- chartInstanceRef.current?.on("click", onClick);
394
+ // Add click event(注册与否看布尔量,实际调用取最新引用)
395
+ const handleClick = (event: echarts.ECElementEvent) => {
396
+ onClickRef.current?.(event);
397
+ };
398
+ if (hasOnClick) {
399
+ chartInstanceRef.current?.on("click", handleClick);
361
400
  }
362
401
 
363
402
  // Legend selection event
364
403
  const handleLegendSelectChanged = (params: unknown) => {
365
- onLegendSelectChanged?.(
404
+ onLegendSelectChangedRef.current?.(
366
405
  (params as { selected: Record<string, boolean> }).selected,
367
406
  );
368
407
  };
369
- if (onLegendSelectChanged) {
408
+ if (hasOnLegendSelectChanged) {
370
409
  chartInstanceRef.current?.on(
371
410
  "legendselectchanged",
372
411
  handleLegendSelectChanged,
@@ -392,7 +431,7 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
392
431
  typeof info?.startValue === "number" &&
393
432
  typeof info?.endValue === "number"
394
433
  ) {
395
- onDataZoomWindowChanged?.({
434
+ emitZoomWindow({
396
435
  startIndex: Math.max(0, Math.round(info.startValue)),
397
436
  endIndex: Math.max(0, Math.round(info.endValue)),
398
437
  });
@@ -400,7 +439,7 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
400
439
  typeof info?.start === "number" &&
401
440
  typeof info?.end === "number"
402
441
  ) {
403
- onDataZoomWindowChanged?.(
442
+ emitZoomWindow(
404
443
  percentWindowToIndexWindow(
405
444
  info.start,
406
445
  info.end,
@@ -409,7 +448,7 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
409
448
  );
410
449
  }
411
450
  };
412
- if (onDataZoomWindowChanged) {
451
+ if (hasOnDataZoomWindowChanged) {
413
452
  chartInstanceRef.current?.on("datazoom", handleDataZoom);
414
453
  }
415
454
 
@@ -445,18 +484,18 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
445
484
  return () => {
446
485
  window.removeEventListener("resize", handleResize);
447
486
 
448
- if (onClick) {
449
- chartInstanceRef.current?.off("click", onClick);
487
+ if (hasOnClick) {
488
+ chartInstanceRef.current?.off("click", handleClick);
450
489
  }
451
490
 
452
- if (onLegendSelectChanged) {
491
+ if (hasOnLegendSelectChanged) {
453
492
  chartInstanceRef.current?.off(
454
493
  "legendselectchanged",
455
494
  handleLegendSelectChanged,
456
495
  );
457
496
  }
458
497
 
459
- if (onDataZoomWindowChanged) {
498
+ if (hasOnDataZoomWindowChanged) {
460
499
  chartInstanceRef.current?.off("datazoom", handleDataZoom);
461
500
  }
462
501
 
@@ -468,9 +507,12 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
468
507
  }, [
469
508
  chartOption,
470
509
  handleResize,
471
- onClick,
472
- onLegendSelectChanged,
473
- onDataZoomWindowChanged,
510
+ onClickRef,
511
+ onLegendSelectChangedRef,
512
+ hasOnClick,
513
+ hasOnLegendSelectChanged,
514
+ hasOnDataZoomWindowChanged,
515
+ emitZoomWindow,
474
516
  xAxisData,
475
517
  enableLegendAutoScroll,
476
518
  legendVisibleCount,
@@ -507,9 +549,9 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
507
549
  startIndex: normalizedScroll.startIndex,
508
550
  autoPlay: normalizedScroll.autoScroll,
509
551
  enableWheelScroll,
510
- onWindowChange: onDataZoomWindowChanged
552
+ onWindowChange: hasOnDataZoomWindowChanged
511
553
  ? (startPercent, endPercent) =>
512
- onDataZoomWindowChanged(
554
+ emitZoomWindow(
513
555
  percentWindowToIndexWindow(
514
556
  startPercent,
515
557
  endPercent,
@@ -525,7 +567,8 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
525
567
  normalizedScroll,
526
568
  sliderDataZoom,
527
569
  xAxisData,
528
- onDataZoomWindowChanged,
570
+ hasOnDataZoomWindowChanged,
571
+ emitZoomWindow,
529
572
  ]);
530
573
 
531
574
  // Clean up resources when the component unmounts
@@ -13,6 +13,7 @@ import {
13
13
  drawCircles,
14
14
  } from "../_utils/bubble";
15
15
  import { useLatestRef } from "../../../hooks/useLatestRef";
16
+ import useShallowStable from "../../../hooks/useShallowStable";
16
17
 
17
18
  export interface BubbleDataItem {
18
19
  name: string;
@@ -71,8 +72,12 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
71
72
  title,
72
73
  onChart,
73
74
  onClick,
74
- ...coreOption
75
+ ...restOption
75
76
  } = props;
77
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
78
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
79
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
80
+ const coreOption = useShallowStable(restOption);
76
81
  const chartRef = useRef<HTMLDivElement>(null);
77
82
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
78
83
  const resizeObserverRef = useRef<ResizeObserver | null>(null);
@@ -373,6 +378,12 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
373
378
 
374
379
  const onChartRef = useLatestRef(onChart);
375
380
 
381
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
382
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
383
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
384
+ const onClickRef = useLatestRef(onClick);
385
+ const hasOnClick = !!onClick;
386
+
376
387
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
377
388
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
378
389
  // Initialize the chart
@@ -398,16 +409,19 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
398
409
  window.addEventListener("resize", handleResize);
399
410
 
400
411
  // Add click event
401
- if (onClick) {
402
- chartInstanceRef.current?.on("click", onClick);
412
+ const handleClick = (event: echarts.ECElementEvent) => {
413
+ onClickRef.current?.(event);
414
+ };
415
+ if (hasOnClick) {
416
+ chartInstanceRef.current?.on("click", handleClick);
403
417
  }
404
418
 
405
419
  // Cleanup function
406
420
  return () => {
407
421
  window.removeEventListener("resize", handleResize);
408
422
 
409
- if (onClick) {
410
- chartInstanceRef.current?.off("click", onClick);
423
+ if (hasOnClick) {
424
+ chartInstanceRef.current?.off("click", handleClick);
411
425
  }
412
426
  };
413
427
  }, [
@@ -416,7 +430,8 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
416
430
  containerSize.height,
417
431
  handleResize,
418
432
  onChartRef,
419
- onClick,
433
+ onClickRef,
434
+ hasOnClick,
420
435
  ]);
421
436
 
422
437
  // Dispose the chart instance when the component unmounts
@@ -4,9 +4,14 @@ import styles from "../index.module.scss";
4
4
  import { ChartCommonProps, ChartOptionType, ChartsOption } from "..";
5
5
  import * as echarts from "echarts";
6
6
  import { useLatestRef } from "../../../hooks/useLatestRef";
7
+ import useShallowStable from "../../../hooks/useShallowStable";
7
8
 
8
9
  const Common: React.FC<ChartCommonProps> = (props) => {
9
- const { className, style, onChart, ...coreOption } = props;
10
+ const { className, style, onChart, ...restOption } = props;
11
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
12
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
13
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
14
+ const coreOption = useShallowStable(restOption);
10
15
  const chartRef = useRef<HTMLDivElement>(null);
11
16
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
12
17
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -10,6 +10,7 @@ import {
10
10
  import React, { useCallback, useEffect, useMemo, useRef } from "react";
11
11
 
12
12
  import styles from "../index.module.scss";
13
+ import useShallowStable from "../../../hooks/useShallowStable";
13
14
 
14
15
  export interface ChartGaugeProps extends ChartCommonProps {
15
16
  color?: string[] | string;
@@ -28,8 +29,12 @@ const Gauge: React.FC<ChartGaugeProps> = (props) => {
28
29
  axisLabel,
29
30
  title,
30
31
  color = "#1675FB",
31
- ...coreSeries
32
+ ...restSeries
32
33
  } = series as echarts.GaugeSeriesOption;
34
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
35
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
36
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
37
+ const coreSeries = useShallowStable(restSeries);
33
38
  const chartRef = useRef<HTMLDivElement>(null);
34
39
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
35
40
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -10,6 +10,7 @@ import {
10
10
  import { resolveChartChrome } from "../_utils/chartTheme";
11
11
  import useIsDark from "../../../hooks/useIsDark";
12
12
  import { useLatestRef } from "../../../hooks/useLatestRef";
13
+ import useShallowStable from "../../../hooks/useShallowStable";
13
14
 
14
15
  export interface HeatmapDataItem {
15
16
  /** X-axis index or name */
@@ -43,6 +44,19 @@ export interface ChartHeatmapProps extends ChartCommonProps {
43
44
  inRangeColor?: string[];
44
45
  }
45
46
 
47
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
48
+ const DEFAULT_IN_RANGE_COLOR = [
49
+ "#8BCCFB",
50
+ "#75C3FB",
51
+ "#5AB5F6",
52
+ "#42AEFA",
53
+ "#2DA1F5",
54
+ "#0F89E1",
55
+ "#056FBB",
56
+ "#025794",
57
+ "#0A4089",
58
+ ];
59
+
46
60
  const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
47
61
  const {
48
62
  className,
@@ -61,19 +75,13 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
61
75
  onChart,
62
76
  onClick,
63
77
  zeroColor,
64
- inRangeColor = [
65
- "#8BCCFB",
66
- "#75C3FB",
67
- "#5AB5F6",
68
- "#42AEFA",
69
- "#2DA1F5",
70
- "#0F89E1",
71
- "#056FBB",
72
- "#025794",
73
- "#0A4089",
74
- ],
75
- ...coreOption
78
+ inRangeColor = DEFAULT_IN_RANGE_COLOR,
79
+ ...restOption
76
80
  } = props;
81
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
82
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
83
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
84
+ const coreOption = useShallowStable(restOption);
77
85
  const chartRef = useRef<HTMLDivElement>(null);
78
86
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
79
87
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -269,6 +277,12 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
269
277
 
270
278
  const onChartRef = useLatestRef(onChart);
271
279
 
280
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
281
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
282
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
283
+ const onClickRef = useLatestRef(onClick);
284
+ const hasOnClick = !!onClick;
285
+
272
286
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
273
287
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
274
288
  // Initialize the chart
@@ -297,19 +311,27 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
297
311
  }
298
312
 
299
313
  // Add click event
300
- if (onClick) {
301
- chartInstanceRef.current?.on("click", onClick);
314
+ const handleClick = (event: echarts.ECElementEvent) => {
315
+ onClickRef.current?.(event);
316
+ };
317
+ if (hasOnClick) {
318
+ chartInstanceRef.current?.on("click", handleClick);
302
319
  }
303
320
 
304
321
  // Cleanup function
305
322
  return () => {
306
323
  window.removeEventListener("resize", handleResize);
307
324
 
308
- if (onClick) {
309
- chartInstanceRef.current?.off("click", onClick);
325
+ if (hasOnClick) {
326
+ chartInstanceRef.current?.off("click", handleClick);
310
327
  }
311
328
  };
312
- }, [chartOption, handleResize, onChartRef, onClick,
329
+ }, [
330
+ chartOption,
331
+ handleResize,
332
+ onChartRef,
333
+ onClickRef,
334
+ hasOnClick,
313
335
  containerReady,
314
336
  ]);
315
337
 
@@ -24,6 +24,8 @@ 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";
28
+ import useShallowStable from "../../../hooks/useShallowStable";
27
29
 
28
30
  export interface ChartLineProps extends ChartCommonProps {
29
31
  chartTitle?: string;
@@ -70,8 +72,12 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
70
72
  enableLegendAutoScroll = false,
71
73
  legendVisibleCount = 8,
72
74
  legendScrollInterval = 1500,
73
- ...coreOption
75
+ ...restOption
74
76
  } = props;
77
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
78
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
79
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
80
+ const coreOption = useShallowStable(restOption);
75
81
  const chartRef = useRef<HTMLDivElement>(null);
76
82
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
77
83
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -90,6 +96,36 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
90
96
  const isDark = useIsDark();
91
97
  const chrome = useMemo(() => resolveChartChrome(isDark), [isDark]);
92
98
 
99
+ // 回调 prop 一律走 useLatestRef,不进依赖数组:消费方传内联箭头(React 里最常见的
100
+ // 写法)时每次渲染都是新引用,进依赖数组会让「init + setOption(notMerge)」整段重跑,
101
+ // 还会把当前可视窗口再同步一次给父级 —— 父级据此改 state 就是死循环。
102
+ // 但「有没有传回调」决定要不要注册监听,所以单独拆成布尔量进依赖数组,
103
+ // 这样「从无到有传入回调」仍会重新注册,不会漏。
104
+ const onClickRef = useLatestRef(onClick);
105
+ const onLegendSelectChangedRef = useLatestRef(onLegendSelectChanged);
106
+ const onDataZoomWindowChangedRef = useLatestRef(onDataZoomWindowChanged);
107
+ const hasOnClick = !!onClick;
108
+ const hasOnLegendSelectChanged = !!onLegendSelectChanged;
109
+ const hasOnDataZoomWindowChanged = !!onDataZoomWindowChanged;
110
+ // 已通知过父级的窗口用 ref 记账(不参与渲染):窗口没变就不再通知,
111
+ // 掐断「通知 → 父级重算坐标轴 → chartOption 变 → effect 重跑 → 再通知」的自激回路
112
+ const notifiedZoomWindowRef = useRef<DataZoomIndexWindow | null>(null);
113
+ const emitZoomWindow = useCallback(
114
+ (next: DataZoomIndexWindow) => {
115
+ const prev = notifiedZoomWindowRef.current;
116
+ if (
117
+ prev &&
118
+ prev.startIndex === next.startIndex &&
119
+ prev.endIndex === next.endIndex
120
+ ) {
121
+ return;
122
+ }
123
+ notifiedZoomWindowRef.current = next;
124
+ onDataZoomWindowChangedRef.current?.(next);
125
+ },
126
+ [onDataZoomWindowChangedRef],
127
+ );
128
+
93
129
  // Cache the chart option with useMemo
94
130
  const chartOption = useMemo(() => {
95
131
  const def_xAxis = createDefaultCategoryXAxis(xAxisData, chrome);
@@ -326,18 +362,18 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
326
362
  }
327
363
 
328
364
  // 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) {
365
+ if (hasOnDataZoomWindowChanged) {
330
366
  const zooms = (chart.getOption() as ChartsOption | undefined)
331
367
  ?.dataZoom as Array<{ start?: number; end?: number }> | undefined;
332
368
  const zoom = zooms?.[0];
333
369
  const totalLen = xAxisData?.length ?? 0;
334
370
  if (typeof zoom?.start === "number" && typeof zoom?.end === "number") {
335
- onDataZoomWindowChanged(
371
+ emitZoomWindow(
336
372
  percentWindowToIndexWindow(zoom.start, zoom.end, totalLen),
337
373
  );
338
374
  } else {
339
375
  // 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({
376
+ emitZoomWindow({
341
377
  startIndex: 0,
342
378
  endIndex: Math.max(0, totalLen - 1),
343
379
  });
@@ -353,18 +389,21 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
353
389
  resizeObserverRef.current.observe(chartRef.current);
354
390
  }
355
391
 
356
- // Add click event
357
- if (onClick) {
358
- chartInstanceRef.current?.on("click", onClick);
392
+ // Add click event(注册与否看布尔量,实际调用取最新引用)
393
+ const handleClick = (event: echarts.ECElementEvent) => {
394
+ onClickRef.current?.(event);
395
+ };
396
+ if (hasOnClick) {
397
+ chartInstanceRef.current?.on("click", handleClick);
359
398
  }
360
399
 
361
400
  // Legend selection event
362
401
  const handleLegendSelectChanged = (params: unknown) => {
363
- onLegendSelectChanged?.(
402
+ onLegendSelectChangedRef.current?.(
364
403
  (params as { selected: Record<string, boolean> }).selected,
365
404
  );
366
405
  };
367
- if (onLegendSelectChanged) {
406
+ if (hasOnLegendSelectChanged) {
368
407
  chartInstanceRef.current?.on(
369
408
  "legendselectchanged",
370
409
  handleLegendSelectChanged,
@@ -390,7 +429,7 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
390
429
  typeof info?.startValue === "number" &&
391
430
  typeof info?.endValue === "number"
392
431
  ) {
393
- onDataZoomWindowChanged?.({
432
+ emitZoomWindow({
394
433
  startIndex: Math.max(0, Math.round(info.startValue)),
395
434
  endIndex: Math.max(0, Math.round(info.endValue)),
396
435
  });
@@ -398,7 +437,7 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
398
437
  typeof info?.start === "number" &&
399
438
  typeof info?.end === "number"
400
439
  ) {
401
- onDataZoomWindowChanged?.(
440
+ emitZoomWindow(
402
441
  percentWindowToIndexWindow(
403
442
  info.start,
404
443
  info.end,
@@ -407,7 +446,7 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
407
446
  );
408
447
  }
409
448
  };
410
- if (onDataZoomWindowChanged) {
449
+ if (hasOnDataZoomWindowChanged) {
411
450
  chartInstanceRef.current?.on("datazoom", handleDataZoom);
412
451
  }
413
452
 
@@ -443,18 +482,18 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
443
482
  return () => {
444
483
  window.removeEventListener("resize", handleResize);
445
484
 
446
- if (onClick) {
447
- chartInstanceRef.current?.off("click", onClick);
485
+ if (hasOnClick) {
486
+ chartInstanceRef.current?.off("click", handleClick);
448
487
  }
449
488
 
450
- if (onLegendSelectChanged) {
489
+ if (hasOnLegendSelectChanged) {
451
490
  chartInstanceRef.current?.off(
452
491
  "legendselectchanged",
453
492
  handleLegendSelectChanged,
454
493
  );
455
494
  }
456
495
 
457
- if (onDataZoomWindowChanged) {
496
+ if (hasOnDataZoomWindowChanged) {
458
497
  chartInstanceRef.current?.off("datazoom", handleDataZoom);
459
498
  }
460
499
 
@@ -466,9 +505,12 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
466
505
  }, [
467
506
  chartOption,
468
507
  handleResize,
469
- onClick,
470
- onLegendSelectChanged,
471
- onDataZoomWindowChanged,
508
+ onClickRef,
509
+ onLegendSelectChangedRef,
510
+ hasOnClick,
511
+ hasOnLegendSelectChanged,
512
+ hasOnDataZoomWindowChanged,
513
+ emitZoomWindow,
472
514
  xAxisData,
473
515
  enableLegendAutoScroll,
474
516
  legendVisibleCount,
@@ -505,9 +547,9 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
505
547
  startIndex: normalizedScroll.startIndex,
506
548
  autoPlay: normalizedScroll.autoScroll,
507
549
  enableWheelScroll,
508
- onWindowChange: onDataZoomWindowChanged
550
+ onWindowChange: hasOnDataZoomWindowChanged
509
551
  ? (startPercent, endPercent) =>
510
- onDataZoomWindowChanged(
552
+ emitZoomWindow(
511
553
  percentWindowToIndexWindow(
512
554
  startPercent,
513
555
  endPercent,
@@ -523,7 +565,8 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
523
565
  normalizedScroll,
524
566
  sliderDataZoom,
525
567
  xAxisData,
526
- onDataZoomWindowChanged,
568
+ hasOnDataZoomWindowChanged,
569
+ emitZoomWindow,
527
570
  ]);
528
571
 
529
572
  // Clean up resources when the component unmounts