@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.
- package/es/components/ChainGraph/_hooks/useChainGraphData.js +29 -8
- package/es/components/ChainGraph/_hooks/useChainGraphLayout.js +14 -3
- package/es/components/Chart/Bar/index.js +55 -20
- package/es/components/Chart/Bubble/index.js +21 -6
- package/es/components/Chart/Common/index.js +6 -1
- package/es/components/Chart/Gauge/index.js +6 -1
- package/es/components/Chart/Heatmap/index.js +24 -7
- package/es/components/Chart/Line/index.js +55 -20
- package/es/components/Chart/Pie/Pie3D/index.js +30 -12
- package/es/components/Chart/Pie/index.js +24 -7
- package/es/components/Chart/Polar/index.js +9 -2
- package/es/components/Chart/Radar/index.js +6 -1
- package/es/components/Chart/Sankey/index.js +11 -2
- package/es/components/Chart/Tree/index.js +11 -2
- package/es/components/Select/_hooks/useSelectComposition.js +6 -2
- package/es/components/Table/_hooks/useAutoScrolling.js +16 -7
- package/es/hooks/useShallowStable.d.ts +32 -0
- package/es/hooks/useShallowStable.js +54 -0
- package/es/layout/Menu/_hooks/useOnlyLvOneMenu.js +9 -2
- package/lib/components/ChainGraph/_hooks/useChainGraphData.js +24 -7
- package/lib/components/ChainGraph/_hooks/useChainGraphLayout.js +12 -2
- package/lib/components/Chart/Bar/index.js +51 -19
- package/lib/components/Chart/Bubble/index.js +20 -6
- package/lib/components/Chart/Common/index.js +6 -1
- package/lib/components/Chart/Gauge/index.js +6 -1
- package/lib/components/Chart/Heatmap/index.js +23 -7
- package/lib/components/Chart/Line/index.js +51 -19
- package/lib/components/Chart/Pie/Pie3D/index.js +29 -12
- package/lib/components/Chart/Pie/index.js +23 -7
- package/lib/components/Chart/Polar/index.js +9 -2
- package/lib/components/Chart/Radar/index.js +6 -1
- package/lib/components/Chart/Sankey/index.js +10 -2
- package/lib/components/Chart/Tree/index.js +10 -2
- package/lib/components/Select/_hooks/useSelectComposition.js +6 -2
- package/lib/components/Table/_hooks/useAutoScrolling.js +16 -7
- package/lib/hooks/useShallowStable.d.ts +32 -0
- package/lib/hooks/useShallowStable.js +59 -0
- package/lib/layout/Menu/_hooks/useOnlyLvOneMenu.js +8 -2
- package/package.json +1 -1
- package/src/__tests__/callbackDepsGuard.test.ts +72 -0
- package/src/__tests__/depsScan.ts +158 -0
- package/src/__tests__/freshObjectDepsGuard.test.ts +144 -0
- package/src/components/ChainGraph/_hooks/callbacks.test.tsx +155 -0
- package/src/components/ChainGraph/_hooks/useChainGraphData.ts +38 -10
- package/src/components/ChainGraph/_hooks/useChainGraphLayout.ts +16 -3
- package/src/components/Chart/Bar/index.tsx +65 -22
- package/src/components/Chart/Bubble/index.tsx +21 -6
- package/src/components/Chart/Common/index.tsx +6 -1
- package/src/components/Chart/Gauge/index.tsx +6 -1
- package/src/components/Chart/Heatmap/index.tsx +39 -17
- package/src/components/Chart/Line/index.tsx +65 -22
- package/src/components/Chart/Pie/Pie3D/index.tsx +27 -8
- package/src/components/Chart/Pie/index.tsx +25 -7
- package/src/components/Chart/Polar/index.tsx +10 -2
- package/src/components/Chart/Radar/index.tsx +6 -1
- package/src/components/Chart/Sankey/index.tsx +10 -4
- package/src/components/Chart/Tree/index.tsx +10 -4
- package/src/components/Chart/callbacks.test.tsx +315 -0
- package/src/components/Chart/optionMemo.test.tsx +232 -0
- package/src/components/Select/_hooks/useSelectComposition.test.ts +47 -0
- package/src/components/Select/_hooks/useSelectComposition.ts +6 -2
- package/src/components/Table/_hooks/useAutoScrolling.test.tsx +95 -0
- package/src/components/Table/_hooks/useAutoScrolling.ts +16 -7
- package/src/hooks/useShallowStable.ts +65 -0
- package/src/layout/Menu/_hooks/useOnlyLvOneMenu.test.tsx +56 -0
- 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 =
|
|
104
|
-
label =
|
|
108
|
+
tooltip = DEFAULT_TOOLTIP,
|
|
109
|
+
label = DEFAULT_LABEL,
|
|
105
110
|
enableMouseControl = false,
|
|
106
111
|
onChart,
|
|
107
112
|
onClick,
|
|
108
|
-
...
|
|
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
|
-
|
|
201
|
-
|
|
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 (
|
|
210
|
-
chart.off("click",
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
287
|
-
|
|
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 (
|
|
311
|
-
chartInstanceRef.current?.off("click",
|
|
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
|
-
|
|
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 =
|
|
36
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
+
});
|