@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
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { act, renderHook } from "@testing-library/react";
3
+
4
+ vi.mock("../ChainGraphServices", () => ({ default: class {} }));
5
+
6
+ import { useChainGraphLayout } from "./useChainGraphLayout";
7
+ import { useChainGraphData } from "./useChainGraphData";
8
+ import type ChainGraphServices from "../ChainGraphServices";
9
+ import type { TreeGraphData } from "..";
10
+
11
+ /**
12
+ * ChainGraph 把回调交给图实例,由图实例在后续事件里回调。
13
+ * 这类「出口型」回调进依赖数组,会让消费方传内联箭头时反复触发重排 / 重新 setData。
14
+ */
15
+
16
+ const DATA = { id: "root", label: "root" } as unknown as TreeGraphData;
17
+
18
+ describe("useChainGraphLayout", () => {
19
+ const makeGraph = () =>
20
+ ({ changeLayout: vi.fn() }) as unknown as ChainGraphServices & {
21
+ changeLayout: ReturnType<typeof vi.fn>;
22
+ };
23
+
24
+ it("getImage 换引用不会重新 changeLayout(整张图重排)", () => {
25
+ const graph = makeGraph();
26
+ const { rerender } = renderHook(
27
+ ({ getImage }: { getImage?: (img: string) => void }) =>
28
+ useChainGraphLayout({ graph, octopus: true, rootLevel: 0, getImage }),
29
+ { initialProps: { getImage: vi.fn() } },
30
+ );
31
+
32
+ expect(graph.changeLayout).toHaveBeenCalledTimes(1);
33
+
34
+ rerender({ getImage: vi.fn() });
35
+ rerender({ getImage: vi.fn() });
36
+
37
+ expect(graph.changeLayout).toHaveBeenCalledTimes(1);
38
+ });
39
+
40
+ it("出图时调到的是最新的 getImage,包括从无到有传入的情况", () => {
41
+ const graph = makeGraph();
42
+ const { rerender } = renderHook(
43
+ ({ getImage }: { getImage?: (img: string) => void }) =>
44
+ useChainGraphLayout({ graph, octopus: true, rootLevel: 0, getImage }),
45
+ { initialProps: {} as { getImage?: (img: string) => void } },
46
+ );
47
+
48
+ const forwarded = graph.changeLayout.mock.calls[0][2] as (
49
+ img: string,
50
+ ) => void;
51
+ // 没传回调时转发函数照样存在,调用不报错
52
+ expect(() => forwarded("img-0")).not.toThrow();
53
+
54
+ const later = vi.fn();
55
+ rerender({ getImage: later });
56
+ // 图实例里存的还是同一个转发函数,但它现在会转给新传入的回调
57
+ forwarded("img-1");
58
+ expect(later).toHaveBeenCalledWith("img-1");
59
+ });
60
+ });
61
+
62
+ describe("useChainGraphData", () => {
63
+ const makeGraph = () =>
64
+ ({ setData: vi.fn() }) as unknown as ChainGraphServices & {
65
+ setData: ReturnType<typeof vi.fn>;
66
+ };
67
+
68
+ type Props = {
69
+ getImage?: (img: string) => void;
70
+ labelRender?: (label: TreeGraphData) => string;
71
+ onLayoutingChange?: (v: boolean) => void;
72
+ };
73
+
74
+ it("三个回调换引用都不会重新 setData", () => {
75
+ const graph = makeGraph();
76
+ const { rerender } = renderHook(
77
+ (props: Props) => useChainGraphData({ graph, data: DATA, ...props }),
78
+ {
79
+ initialProps: {
80
+ getImage: vi.fn(),
81
+ labelRender: vi.fn(() => "a"),
82
+ onLayoutingChange: vi.fn(),
83
+ } as Props,
84
+ },
85
+ );
86
+
87
+ expect(graph.setData).toHaveBeenCalledTimes(1);
88
+
89
+ rerender({
90
+ getImage: vi.fn(),
91
+ labelRender: vi.fn(() => "b"),
92
+ onLayoutingChange: vi.fn(),
93
+ });
94
+
95
+ expect(graph.setData).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it("图实例回调时取到的是最新引用,不是首次 setData 时的那个", () => {
99
+ const graph = makeGraph();
100
+ const firstLabel = vi.fn(() => "first");
101
+ const { rerender } = renderHook(
102
+ (props: Props) => useChainGraphData({ graph, data: DATA, ...props }),
103
+ {
104
+ initialProps: {
105
+ getImage: vi.fn(),
106
+ labelRender: firstLabel,
107
+ onLayoutingChange: vi.fn(),
108
+ } as Props,
109
+ },
110
+ );
111
+
112
+ const pushed = graph.setData.mock.calls[0][0] as {
113
+ getImage: (img: string) => void;
114
+ labelRender?: (label: TreeGraphData) => string;
115
+ isLayouting: (v: boolean) => void;
116
+ };
117
+
118
+ const nextImage = vi.fn();
119
+ const nextLabel = vi.fn(() => "next");
120
+ const nextLayouting = vi.fn();
121
+ rerender({
122
+ getImage: nextImage,
123
+ labelRender: nextLabel,
124
+ onLayoutingChange: nextLayouting,
125
+ });
126
+
127
+ pushed.getImage("img");
128
+ expect(nextImage).toHaveBeenCalledWith("img");
129
+
130
+ expect(pushed.labelRender!(DATA)).toBe("next");
131
+ expect(firstLabel).not.toHaveBeenCalled();
132
+
133
+ act(() => pushed.isLayouting(false));
134
+ expect(nextLayouting).toHaveBeenCalledWith(false);
135
+ });
136
+
137
+ it("labelRender 的「传了才改写 label」语义保留:没传就是 undefined,从无到有会带上", () => {
138
+ const graph = makeGraph();
139
+ const { rerender } = renderHook(
140
+ ({ data, labelRender }: { data: TreeGraphData; labelRender?: Props["labelRender"] }) =>
141
+ useChainGraphData({ graph, data, labelRender }),
142
+ { initialProps: { data: DATA } as { data: TreeGraphData; labelRender?: Props["labelRender"] } },
143
+ );
144
+
145
+ expect(graph.setData.mock.calls[0][0].labelRender).toBeUndefined();
146
+
147
+ const label = vi.fn(() => "x");
148
+ const nextData = { id: "root2", label: "root2" } as unknown as TreeGraphData;
149
+ rerender({ data: nextData, labelRender: label });
150
+
151
+ expect(graph.setData).toHaveBeenCalledTimes(2);
152
+ expect(typeof graph.setData.mock.calls[1][0].labelRender).toBe("function");
153
+ expect(graph.setData.mock.calls[1][0].labelRender(DATA)).toBe("x");
154
+ });
155
+ });
@@ -1,7 +1,8 @@
1
- import { useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useState } from "react";
2
2
  import { Equal } from "hsu-utils";
3
3
  import ChainGraphServices from "../ChainGraphServices";
4
4
  import { TreeGraphData } from "..";
5
+ import { useLatestRef } from "../../../hooks/useLatestRef";
5
6
 
6
7
  interface UseChainGraphDataProps {
7
8
  graph: ChainGraphServices | null;
@@ -28,6 +29,35 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
28
29
  );
29
30
  const [isLayouting, setIsLayouting] = useState(true);
30
31
 
32
+ // 这三个回调都是「交给图实例、由它在后续事件里回调」的出口,不是数据本身。
33
+ // 它们进依赖数组是条死依赖:effect 体被 ObjEqual(lastData, data) 挡住,
34
+ // 引用变了也不会重新 setData —— 图里存的还是首次 setData 时那个闭包,
35
+ // 换了引用永远调不到(stale closure)。改成引用恒定的转发函数:
36
+ // 依赖数组只留真正的输入(graph/data/level/rootLevel),调用时总是取到最新的回调。
37
+ const getImageRef = useLatestRef(getImage);
38
+ const labelRenderRef = useLatestRef(labelRender);
39
+ const onLayoutingChangeRef = useLatestRef(onLayoutingChange);
40
+
41
+ const emitImage = useCallback(
42
+ (img: string) => {
43
+ getImageRef.current?.(img);
44
+ },
45
+ [getImageRef]
46
+ );
47
+ // labelRender 在 ChainGraphServices.setData 里是「传了才改写 label」的真值判断,
48
+ // 所以这里保留「有没有传」的语义:没传就传 undefined,传了才给转发函数。
49
+ const renderLabel = useCallback(
50
+ (label: TreeGraphData) => labelRenderRef.current!(label),
51
+ [labelRenderRef]
52
+ );
53
+ const emitLayouting = useCallback(
54
+ (value: boolean) => {
55
+ setIsLayouting(value);
56
+ onLayoutingChangeRef.current?.(value);
57
+ },
58
+ [onLayoutingChangeRef]
59
+ );
60
+
31
61
  useEffect(() => {
32
62
  if (graph && !Equal.ObjEqual(lastData, data)) {
33
63
  setLastData(data);
@@ -35,12 +65,9 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
35
65
  data,
36
66
  level,
37
67
  rootLevel,
38
- getImage,
39
- isLayouting: (value) => {
40
- setIsLayouting(value);
41
- onLayoutingChange?.(value);
42
- },
43
- labelRender,
68
+ getImage: emitImage,
69
+ isLayouting: emitLayouting,
70
+ labelRender: labelRenderRef.current ? renderLabel : undefined,
44
71
  });
45
72
  }
46
73
  }, [
@@ -48,10 +75,11 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
48
75
  data,
49
76
  level,
50
77
  rootLevel,
51
- getImage,
52
- labelRender,
53
- onLayoutingChange,
54
78
  lastData,
79
+ emitImage,
80
+ emitLayouting,
81
+ renderLabel,
82
+ labelRenderRef,
55
83
  ]);
56
84
 
57
85
  return { isLayouting };
@@ -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
@@ -12,6 +12,7 @@ import {
12
12
  defaultBubbleColorList,
13
13
  drawCircles,
14
14
  } from "../_utils/bubble";
15
+ import { useLatestRef } from "../../../hooks/useLatestRef";
15
16
 
16
17
  export interface BubbleDataItem {
17
18
  name: string;
@@ -370,6 +371,16 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
370
371
  }
371
372
  }, [chartOption, data]);
372
373
 
374
+ const onChartRef = useLatestRef(onChart);
375
+
376
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
377
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
378
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
379
+ const onClickRef = useLatestRef(onClick);
380
+ const hasOnClick = !!onClick;
381
+
382
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
383
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
373
384
  // Initialize the chart
374
385
  useEffect(() => {
375
386
  if (!chartRef.current) return;
@@ -382,7 +393,7 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
382
393
  chart = echarts.init(chartRef.current);
383
394
  chartInstanceRef.current = chart;
384
395
  // Call the onChart callback on first initialization
385
- onChart?.(chart);
396
+ onChartRef.current?.(chart);
386
397
  }
387
398
 
388
399
  // Resize first so echarts picks up the correct container size, then apply the option
@@ -393,16 +404,19 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
393
404
  window.addEventListener("resize", handleResize);
394
405
 
395
406
  // Add click event
396
- if (onClick) {
397
- 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);
398
412
  }
399
413
 
400
414
  // Cleanup function
401
415
  return () => {
402
416
  window.removeEventListener("resize", handleResize);
403
417
 
404
- if (onClick) {
405
- chartInstanceRef.current?.off("click", onClick);
418
+ if (hasOnClick) {
419
+ chartInstanceRef.current?.off("click", handleClick);
406
420
  }
407
421
  };
408
422
  }, [
@@ -410,8 +424,9 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
410
424
  containerSize.width,
411
425
  containerSize.height,
412
426
  handleResize,
413
- onChart,
414
- onClick,
427
+ onChartRef,
428
+ onClickRef,
429
+ hasOnClick,
415
430
  ]);
416
431
 
417
432
  // Dispose the chart instance when the component unmounts
@@ -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
  const Common: React.FC<ChartCommonProps> = (props) => {
8
9
  const { className, style, onChart, ...coreOption } = props;
@@ -31,6 +32,10 @@ const Common: React.FC<ChartCommonProps> = (props) => {
31
32
  chartInstanceRef.current?.resize();
32
33
  }, []);
33
34
 
35
+ const onChartRef = useLatestRef(onChart);
36
+
37
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
38
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
34
39
  // Initialize the chart
35
40
  useEffect(() => {
36
41
  if (!chartRef.current || !containerReady) return;
@@ -46,7 +51,7 @@ const Common: React.FC<ChartCommonProps> = (props) => {
46
51
  chart.setOption(chartOption as ChartOptionType, true);
47
52
 
48
53
  // Chart-ready callback (can be used for legend auto-scroll, etc.)
49
- onChart?.(chart);
54
+ onChartRef.current?.(chart);
50
55
 
51
56
  // Add resize listener
52
57
  window.addEventListener("resize", handleResize);
@@ -61,7 +66,7 @@ const Common: React.FC<ChartCommonProps> = (props) => {
61
66
  return () => {
62
67
  window.removeEventListener("resize", handleResize);
63
68
  };
64
- }, [chartOption, handleResize, onChart,
69
+ }, [chartOption, handleResize, onChartRef,
65
70
  containerReady,
66
71
  ]);
67
72
 
@@ -9,6 +9,7 @@ import {
9
9
  } from "../_utils/heatmap";
10
10
  import { resolveChartChrome } from "../_utils/chartTheme";
11
11
  import useIsDark from "../../../hooks/useIsDark";
12
+ import { useLatestRef } from "../../../hooks/useLatestRef";
12
13
 
13
14
  export interface HeatmapDataItem {
14
15
  /** X-axis index or name */
@@ -266,6 +267,16 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
266
267
  chartInstanceRef.current?.resize();
267
268
  }, []);
268
269
 
270
+ const onChartRef = useLatestRef(onChart);
271
+
272
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
273
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
274
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
275
+ const onClickRef = useLatestRef(onClick);
276
+ const hasOnClick = !!onClick;
277
+
278
+ // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
279
+ // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
269
280
  // Initialize the chart
270
281
  useEffect(() => {
271
282
  if (!chartRef.current || !containerReady) return;
@@ -276,7 +287,7 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
276
287
  chart = echarts.init(chartRef.current);
277
288
  chartInstanceRef.current = chart;
278
289
  // Call the onChart callback on first initialization
279
- onChart?.(chart);
290
+ onChartRef.current?.(chart);
280
291
  }
281
292
 
282
293
  // Apply the option
@@ -292,19 +303,27 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
292
303
  }
293
304
 
294
305
  // Add click event
295
- if (onClick) {
296
- 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);
297
311
  }
298
312
 
299
313
  // Cleanup function
300
314
  return () => {
301
315
  window.removeEventListener("resize", handleResize);
302
316
 
303
- if (onClick) {
304
- chartInstanceRef.current?.off("click", onClick);
317
+ if (hasOnClick) {
318
+ chartInstanceRef.current?.off("click", handleClick);
305
319
  }
306
320
  };
307
- }, [chartOption, handleResize, onChart, onClick,
321
+ }, [
322
+ chartOption,
323
+ handleResize,
324
+ onChartRef,
325
+ onClickRef,
326
+ hasOnClick,
308
327
  containerReady,
309
328
  ]);
310
329