@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
@@ -8,6 +8,7 @@ import { useMouseOverHandler, useGlobalOutHandler } from "./_hooks";
8
8
  import { getPie3DOption } from "./_utils/option";
9
9
  import styles from "../../index.module.scss";
10
10
  import { useLatestRef } from "../../../../hooks/useLatestRef";
11
+ import useShallowStable from "../../../../hooks/useShallowStable";
11
12
 
12
13
  export interface Pie3DDataItem {
13
14
  name: string;
@@ -86,6 +87,10 @@ export interface SeriesItem {
86
87
  };
87
88
  }
88
89
 
90
+ // 解构默认值写成字面量会每次渲染新建一个对象,进依赖数组就让 memo 恒不命中;提到模块级常量
91
+ const DEFAULT_TOOLTIP = { show: false };
92
+ const DEFAULT_LABEL: LabelConfig = { show: true };
93
+
89
94
  const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
90
95
  const {
91
96
  className,
@@ -100,13 +105,17 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
100
105
  minHeight = 1,
101
106
  maxHeight = 10,
102
107
  yOffset = -0.2,
103
- tooltip = { show: false },
104
- label = { show: true },
108
+ tooltip = DEFAULT_TOOLTIP,
109
+ label = DEFAULT_LABEL,
105
110
  enableMouseControl = false,
106
111
  onChart,
107
112
  onClick,
108
- ...coreOption
113
+ ...restOption
109
114
  } = props;
115
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
116
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
117
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
118
+ const coreOption = useShallowStable(restOption);
110
119
 
111
120
  const chartRef = useRef<HTMLDivElement>(null);
112
121
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
@@ -173,6 +182,12 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
173
182
 
174
183
  const onChartRef = useLatestRef(onChart);
175
184
 
185
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑会重建
186
+ // 3D 饼的 option 并重播动画。注册与否看布尔量(保证「从无到有传入」仍会注册),
187
+ // 实际调用取 ref 里的最新引用。
188
+ const onClickRef = useLatestRef(onClick);
189
+ const hasOnClick = !!onClick;
190
+
176
191
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
177
192
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
178
193
  useEffect(() => {
@@ -197,8 +212,11 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
197
212
 
198
213
  chart.on("mouseover", handleMouseOver);
199
214
  chart.on("globalout", handleGlobalOut);
200
- if (onClick) {
201
- chart.on("click", onClick);
215
+ const handleClick = (event: echarts.ECElementEvent) => {
216
+ onClickRef.current?.(event);
217
+ };
218
+ if (hasOnClick) {
219
+ chart.on("click", handleClick);
202
220
  }
203
221
 
204
222
  return () => {
@@ -206,8 +224,8 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
206
224
  if (chart) {
207
225
  chart.off("mouseover", handleMouseOver);
208
226
  chart.off("globalout", handleGlobalOut);
209
- if (onClick) {
210
- chart.off("click", onClick);
227
+ if (hasOnClick) {
228
+ chart.off("click", handleClick);
211
229
  }
212
230
  }
213
231
  };
@@ -217,7 +235,8 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
217
235
  handleMouseOver,
218
236
  handleGlobalOut,
219
237
  onChartRef,
220
- onClick,
238
+ onClickRef,
239
+ hasOnClick,
221
240
  containerReady,
222
241
  ]);
223
242
 
@@ -14,6 +14,7 @@ import { autoScrollLegend } from "../chartUtils";
14
14
  import styles from "../index.module.scss";
15
15
  import ChartPie3D, { ChartPie3DProps } from "./Pie3D";
16
16
  import { useLatestRef } from "../../../hooks/useLatestRef";
17
+ import useShallowStable from "../../../hooks/useShallowStable";
17
18
 
18
19
  export interface ChartPieProps extends ChartCommonProps {
19
20
  chartTitle?: string;
@@ -43,6 +44,9 @@ export interface ChartPieFC extends React.FC<ChartPieProps> {
43
44
  Three: React.FC<ChartPie3DProps>;
44
45
  }
45
46
 
47
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
48
+ const EMPTY_EXTEND_SERIES: Series[] = [];
49
+
46
50
  const ChartPie: ChartPieFC = (props) => {
47
51
  const {
48
52
  className,
@@ -54,7 +58,7 @@ const ChartPie: ChartPieFC = (props) => {
54
58
  series,
55
59
  title,
56
60
  onChart,
57
- extendSeries = [],
61
+ extendSeries = EMPTY_EXTEND_SERIES,
58
62
  onClick,
59
63
  enableLegendAutoScroll = false,
60
64
  legendVisibleCount = 8,
@@ -64,8 +68,12 @@ const ChartPie: ChartPieFC = (props) => {
64
68
  spanAngle = 180,
65
69
  center,
66
70
  radius,
67
- ...coreOption
71
+ ...restOption
68
72
  } = props;
73
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
74
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
75
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
76
+ const coreOption = useShallowStable(restOption);
69
77
  const chartRef = useRef<HTMLDivElement>(null);
70
78
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
71
79
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -255,6 +263,12 @@ const ChartPie: ChartPieFC = (props) => {
255
263
 
256
264
  const onChartRef = useLatestRef(onChart);
257
265
 
266
+ // onClick 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 跟着重跑就会
267
+ // 再走一遍 setOption(notMerge),动画重播、悬浮/高亮态被清掉。注册与否看布尔量,
268
+ // 这样「从无到有传入 onClick」仍会注册;实际调用取 ref 里的最新引用。
269
+ const onClickRef = useLatestRef(onClick);
270
+ const hasOnClick = !!onClick;
271
+
258
272
  // 回调 prop 不进依赖数组:消费方传内联箭头时每次渲染都是新引用,effect 会跟着
259
273
  // 重跑并再调一次回调 —— 回调里 setState 就是死循环(详见 Input/TextArea 的说明)
260
274
  // Initialize the chart
@@ -283,8 +297,11 @@ const ChartPie: ChartPieFC = (props) => {
283
297
  }
284
298
 
285
299
  // Add click event
286
- if (onClick) {
287
- chartInstanceRef.current?.on("click", onClick);
300
+ const handleClick = (event: echarts.ECElementEvent) => {
301
+ onClickRef.current?.(event);
302
+ };
303
+ if (hasOnClick) {
304
+ chartInstanceRef.current?.on("click", handleClick);
288
305
  }
289
306
 
290
307
  // Legend auto-scroll
@@ -307,8 +324,8 @@ const ChartPie: ChartPieFC = (props) => {
307
324
  return () => {
308
325
  window.removeEventListener("resize", handleResize);
309
326
 
310
- if (onClick) {
311
- chartInstanceRef.current?.off("click", onClick);
327
+ if (hasOnClick) {
328
+ chartInstanceRef.current?.off("click", handleClick);
312
329
  }
313
330
 
314
331
  // Clean up legend scrolling
@@ -321,7 +338,8 @@ const ChartPie: ChartPieFC = (props) => {
321
338
  chartOption,
322
339
  handleResize,
323
340
  onChartRef,
324
- onClick,
341
+ onClickRef,
342
+ hasOnClick,
325
343
  enableLegendAutoScroll,
326
344
  seriesData,
327
345
  legendVisibleCount,
@@ -8,6 +8,7 @@ import {
8
8
  } from "..";
9
9
  import React, { useCallback, useEffect, useMemo, useRef } from "react";
10
10
  import styles from "../index.module.scss";
11
+ import useShallowStable from "../../../hooks/useShallowStable";
11
12
 
12
13
  export interface ChartPolarProps extends ChartCommonProps {
13
14
  title1?: string;
@@ -18,6 +19,9 @@ export interface ChartPolarProps extends ChartCommonProps {
18
19
  color?: string[] | string;
19
20
  }
20
21
 
22
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
23
+ const DEFAULT_COLOR = ["#00FFA3", "#00FFA3"];
24
+
21
25
  const Polar: React.FC<ChartPolarProps> = (props) => {
22
26
  const {
23
27
  className,
@@ -32,9 +36,13 @@ const Polar: React.FC<ChartPolarProps> = (props) => {
32
36
  title2Style,
33
37
  textStyle,
34
38
  seriesData,
35
- color = ["#00FFA3", "#00FFA3"],
36
- ...coreOption
39
+ color = DEFAULT_COLOR,
40
+ ...restOption
37
41
  } = props;
42
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
43
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
44
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
45
+ const coreOption = useShallowStable(restOption);
38
46
  const chartRef = useRef<HTMLDivElement>(null);
39
47
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
40
48
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -3,6 +3,7 @@ import useContainerReady from "../_hooks/useContainerReady";
3
3
  import { ChartCommonProps, ChartOptionType, ChartsOption } from "..";
4
4
  import React, { useCallback, useEffect, useMemo, useRef } from "react";
5
5
  import styles from "../index.module.scss";
6
+ import useShallowStable from "../../../hooks/useShallowStable";
6
7
 
7
8
  export interface RadarIndicator {
8
9
  name: string;
@@ -51,8 +52,12 @@ const Radar: React.FC<ChartRadarProps> = (props) => {
51
52
  singleIndicatorTooltip: singleIndicatorTooltipProp = false,
52
53
  singleIndicatorMode,
53
54
  singleIndicatorHoverFill,
54
- ...coreOption
55
+ ...restOption
55
56
  } = props;
57
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
58
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
59
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
60
+ const coreOption = useShallowStable(restOption);
56
61
 
57
62
  const chartRef = useRef<HTMLDivElement>(null);
58
63
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
@@ -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(() => {
@@ -0,0 +1,315 @@
1
+ import React, { useState } from "react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, render } from "@testing-library/react";
4
+
5
+ /**
6
+ * 这一组用例盯的是同一条缺陷:**回调 prop 进 effect 依赖数组**。
7
+ *
8
+ * 消费方写内联箭头(`onXxx={(v) => setV(v)}`)时每次渲染都是新引用,
9
+ * 回调进依赖数组 → effect 重跑 → 重新 setOption / 重新注册监听 / 再发一次通知,
10
+ * 通知里再 setState 就是死循环。
11
+ *
12
+ * 但这些回调同时还被当作「要不要注册这个监听」的真值判断,
13
+ * 所以修法不能是「把回调从依赖数组里删掉」了事——
14
+ * 「从无到有传入回调」必须仍然能正确注册,这里每个组件都单独验一条。
15
+ */
16
+
17
+ type Handler = (params: unknown) => void;
18
+
19
+ const chart = {
20
+ setOption: vi.fn(),
21
+ resize: vi.fn(),
22
+ dispose: vi.fn(),
23
+ getDataURL: vi.fn(() => "data:image/png;base64,AAAA"),
24
+ getOption: vi.fn(() => ({ dataZoom: [{ start: 0, end: 100 }] })),
25
+ on: vi.fn(),
26
+ off: vi.fn(),
27
+ };
28
+ const handlers = new Map<string, Set<Handler>>();
29
+
30
+ chart.on.mockImplementation(((event: string, handler: Handler) => {
31
+ if (!handlers.has(event)) handlers.set(event, new Set());
32
+ handlers.get(event)!.add(handler);
33
+ }) as never);
34
+ chart.off.mockImplementation(((event: string, handler?: Handler) => {
35
+ if (!handler) handlers.delete(event);
36
+ else handlers.get(event)?.delete(handler);
37
+ }) as never);
38
+
39
+ const emit = (event: string, params?: unknown) => {
40
+ act(() => {
41
+ handlers.get(event)?.forEach((h) => h(params));
42
+ });
43
+ };
44
+ const listenerCount = (event: string) => handlers.get(event)?.size ?? 0;
45
+
46
+ vi.mock("echarts", () => ({
47
+ init: () => chart,
48
+ use: () => {},
49
+ registerTheme: () => {},
50
+ graphic: {},
51
+ }));
52
+ vi.mock("echarts-gl", () => ({}));
53
+
54
+ import ChartBar from "./Bar";
55
+ import ChartLine from "./Line";
56
+ import ChartSankey from "./Sankey";
57
+ import ChartTree from "./Tree";
58
+ import ChartPie from "./Pie";
59
+
60
+ // 容器必须量得到尺寸,否则 useContainerReady 不放行 echarts.init
61
+ beforeEach(() => {
62
+ handlers.clear();
63
+ vi.clearAllMocks();
64
+ chart.getOption.mockImplementation(() => ({
65
+ dataZoom: [{ start: 0, end: 100 }],
66
+ }));
67
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
68
+ width: 600,
69
+ height: 400,
70
+ top: 0,
71
+ left: 0,
72
+ right: 600,
73
+ bottom: 400,
74
+ x: 0,
75
+ y: 0,
76
+ toJSON: () => ({}),
77
+ } as DOMRect);
78
+ });
79
+
80
+ const X_AXIS = ["一", "二", "三", "四"];
81
+ const SERIES = [{ name: "A", value: [1, 2, 3, 4] }];
82
+ const SANKEY_NODES = [{ name: "a" }, { name: "b" }];
83
+ const SANKEY_LINKS = [{ source: "a", target: "b" }];
84
+ const TREE_DATA = [{ name: "root", value: 1, children: [] }];
85
+ const PIE_DATA = [{ name: "a", value: 1 }];
86
+
87
+ describe("Chart.Bar / Chart.Line 的回调 prop", () => {
88
+ const cases = [
89
+ ["Chart.Bar", ChartBar],
90
+ ["Chart.Line", ChartLine],
91
+ ] as const;
92
+
93
+ for (const [name, Comp] of cases) {
94
+ describe(name, () => {
95
+ it("内联 onDataZoomWindowChanged 写回 state 不会死循环,窗口没变也不重复通知", () => {
96
+ const spy = vi.fn();
97
+
98
+ function Consumer() {
99
+ const [, setWin] = useState<unknown>(null);
100
+
101
+ return (
102
+ <Comp
103
+ xAxisData={X_AXIS}
104
+ seriesData={SERIES}
105
+ // 内联箭头:每次渲染都是新引用
106
+ onDataZoomWindowChanged={(w) => {
107
+ spy(w);
108
+ setWin(w);
109
+ }}
110
+ />
111
+ );
112
+ }
113
+
114
+ render(<Consumer />);
115
+
116
+ // 初次同步一次窗口;进了循环这里会是几十上百次
117
+ expect(spy).toHaveBeenCalledTimes(1);
118
+ expect(spy).toHaveBeenCalledWith({ startIndex: 0, endIndex: 3 });
119
+ });
120
+
121
+ it("onClick 换引用不重复注册监听,且调到的是最新的那个", () => {
122
+ const first = vi.fn();
123
+ const second = vi.fn();
124
+
125
+ const { rerender } = render(
126
+ <Comp xAxisData={X_AXIS} seriesData={SERIES} onClick={first} />,
127
+ );
128
+ expect(listenerCount("click")).toBe(1);
129
+
130
+ rerender(
131
+ <Comp xAxisData={X_AXIS} seriesData={SERIES} onClick={second} />,
132
+ );
133
+ // 换引用不该拆装监听,更不该重新 setOption
134
+ expect(listenerCount("click")).toBe(1);
135
+
136
+ emit("click", { name: "x" });
137
+ expect(first).not.toHaveBeenCalled();
138
+ expect(second).toHaveBeenCalledTimes(1);
139
+ });
140
+
141
+ it("onLegendSelectChanged 从无到有传入时仍会注册", () => {
142
+ const { rerender } = render(
143
+ <Comp xAxisData={X_AXIS} seriesData={SERIES} />,
144
+ );
145
+ // 没传就不注册
146
+ expect(listenerCount("legendselectchanged")).toBe(0);
147
+
148
+ const spy = vi.fn();
149
+ rerender(
150
+ <Comp
151
+ xAxisData={X_AXIS}
152
+ seriesData={SERIES}
153
+ onLegendSelectChanged={spy}
154
+ />,
155
+ );
156
+ expect(listenerCount("legendselectchanged")).toBe(1);
157
+
158
+ emit("legendselectchanged", { selected: { A: false } });
159
+ expect(spy).toHaveBeenCalledWith({ A: false });
160
+ });
161
+
162
+ it("onDataZoomWindowChanged 从无到有传入时仍会注册并同步一次窗口", () => {
163
+ const { rerender } = render(
164
+ <Comp xAxisData={X_AXIS} seriesData={SERIES} />,
165
+ );
166
+ expect(listenerCount("datazoom")).toBe(0);
167
+
168
+ const spy = vi.fn();
169
+ rerender(
170
+ <Comp
171
+ xAxisData={X_AXIS}
172
+ seriesData={SERIES}
173
+ onDataZoomWindowChanged={spy}
174
+ />,
175
+ );
176
+ expect(listenerCount("datazoom")).toBe(1);
177
+ expect(spy).toHaveBeenCalledWith({ startIndex: 0, endIndex: 3 });
178
+
179
+ spy.mockClear();
180
+ emit("datazoom", { start: 25, end: 100 });
181
+ expect(spy).toHaveBeenCalledWith({ startIndex: 1, endIndex: 3 });
182
+ });
183
+
184
+ it("传回调后又撤掉时监听会摘干净", () => {
185
+ const { rerender } = render(
186
+ <Comp
187
+ xAxisData={X_AXIS}
188
+ seriesData={SERIES}
189
+ onLegendSelectChanged={() => {}}
190
+ />,
191
+ );
192
+ expect(listenerCount("legendselectchanged")).toBe(1);
193
+
194
+ rerender(<Comp xAxisData={X_AXIS} seriesData={SERIES} />);
195
+ expect(listenerCount("legendselectchanged")).toBe(0);
196
+ });
197
+ });
198
+ }
199
+ });
200
+
201
+ describe("Chart.Sankey / Chart.Tree 的 getImage", () => {
202
+ it("Sankey:getImage 换引用不重新 setOption,出图时调到的是最新的那个", () => {
203
+ const first = vi.fn();
204
+ const second = vi.fn();
205
+
206
+ const { rerender } = render(
207
+ <ChartSankey
208
+ seriesData={SANKEY_NODES}
209
+ seriesLinks={SANKEY_LINKS}
210
+ getImage={first}
211
+ />,
212
+ );
213
+ const before = chart.setOption.mock.calls.length;
214
+
215
+ rerender(
216
+ <ChartSankey
217
+ seriesData={SANKEY_NODES}
218
+ seriesLinks={SANKEY_LINKS}
219
+ getImage={second}
220
+ />,
221
+ );
222
+ // 出图回调不是画图的输入,换引用不该让图重画
223
+ expect(chart.setOption.mock.calls.length).toBe(before);
224
+
225
+ emit("finished");
226
+ expect(first).not.toHaveBeenCalled();
227
+ expect(second).toHaveBeenCalledTimes(1);
228
+ });
229
+
230
+ it("Sankey:内联 getImage 把图存进 state 不会死循环", () => {
231
+ const spy = vi.fn();
232
+
233
+ function Consumer() {
234
+ const [, setImg] = useState("");
235
+
236
+ return (
237
+ <ChartSankey
238
+ seriesData={SANKEY_NODES}
239
+ seriesLinks={SANKEY_LINKS}
240
+ getImage={(img) => {
241
+ spy(img);
242
+ setImg(img);
243
+ }}
244
+ />
245
+ );
246
+ }
247
+
248
+ render(<Consumer />);
249
+ emit("finished");
250
+ emit("finished");
251
+
252
+ // finished 触发几次就回调几次,不会自激放大
253
+ expect(spy).toHaveBeenCalledTimes(2);
254
+ });
255
+
256
+ it("Tree:getImage 换引用不重新 setOption,出图时调到的是最新的那个", () => {
257
+ const first = vi.fn();
258
+ const second = vi.fn();
259
+
260
+ const { rerender } = render(
261
+ <ChartTree seriesData={TREE_DATA} getImage={first} />,
262
+ );
263
+ const before = chart.setOption.mock.calls.length;
264
+
265
+ rerender(<ChartTree seriesData={TREE_DATA} getImage={second} />);
266
+ expect(chart.setOption.mock.calls.length).toBe(before);
267
+
268
+ emit("finished");
269
+ expect(first).not.toHaveBeenCalled();
270
+ expect(second).toHaveBeenCalledTimes(1);
271
+ });
272
+
273
+ it("Tree:从无到有传入 getImage 时仍能拿到图", () => {
274
+ const spy = vi.fn();
275
+ const { rerender } = render(<ChartTree seriesData={TREE_DATA} />);
276
+
277
+ emit("finished");
278
+ expect(spy).not.toHaveBeenCalled();
279
+
280
+ rerender(<ChartTree seriesData={TREE_DATA} getImage={spy} />);
281
+ emit("finished");
282
+ expect(spy).toHaveBeenCalledTimes(1);
283
+ });
284
+ });
285
+
286
+ describe("Chart.Pie 的 onClick", () => {
287
+ it("换引用不重复注册,且调到最新引用", () => {
288
+ const first = vi.fn();
289
+ const second = vi.fn();
290
+
291
+ const { rerender } = render(
292
+ <ChartPie seriesData={PIE_DATA} onClick={first} />,
293
+ );
294
+ expect(listenerCount("click")).toBe(1);
295
+
296
+ rerender(<ChartPie seriesData={PIE_DATA} onClick={second} />);
297
+ expect(listenerCount("click")).toBe(1);
298
+
299
+ emit("click", { name: "a" });
300
+ expect(first).not.toHaveBeenCalled();
301
+ expect(second).toHaveBeenCalledTimes(1);
302
+ });
303
+
304
+ it("从无到有传入 onClick 时仍会注册", () => {
305
+ const spy = vi.fn();
306
+ const { rerender } = render(<ChartPie seriesData={PIE_DATA} />);
307
+ expect(listenerCount("click")).toBe(0);
308
+
309
+ rerender(<ChartPie seriesData={PIE_DATA} onClick={spy} />);
310
+ expect(listenerCount("click")).toBe(1);
311
+
312
+ emit("click", { name: "a" });
313
+ expect(spy).toHaveBeenCalledTimes(1);
314
+ });
315
+ });