@hsu-react/ui 2.5.5 → 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 (40) hide show
  1. package/es/components/Chart/Bar/index.js +6 -1
  2. package/es/components/Chart/Bubble/index.js +6 -1
  3. package/es/components/Chart/Common/index.js +6 -1
  4. package/es/components/Chart/Gauge/index.js +6 -1
  5. package/es/components/Chart/Heatmap/index.js +9 -2
  6. package/es/components/Chart/Line/index.js +6 -1
  7. package/es/components/Chart/Pie/Pie3D/index.js +15 -7
  8. package/es/components/Chart/Pie/index.js +9 -2
  9. package/es/components/Chart/Polar/index.js +9 -2
  10. package/es/components/Chart/Radar/index.js +6 -1
  11. package/es/hooks/useShallowStable.d.ts +32 -0
  12. package/es/hooks/useShallowStable.js +54 -0
  13. package/lib/components/Chart/Bar/index.js +6 -1
  14. package/lib/components/Chart/Bubble/index.js +6 -1
  15. package/lib/components/Chart/Common/index.js +6 -1
  16. package/lib/components/Chart/Gauge/index.js +6 -1
  17. package/lib/components/Chart/Heatmap/index.js +9 -2
  18. package/lib/components/Chart/Line/index.js +6 -1
  19. package/lib/components/Chart/Pie/Pie3D/index.js +15 -7
  20. package/lib/components/Chart/Pie/index.js +9 -2
  21. package/lib/components/Chart/Polar/index.js +9 -2
  22. package/lib/components/Chart/Radar/index.js +6 -1
  23. package/lib/hooks/useShallowStable.d.ts +32 -0
  24. package/lib/hooks/useShallowStable.js +59 -0
  25. package/package.json +1 -1
  26. package/src/__tests__/callbackDepsGuard.test.ts +4 -51
  27. package/src/__tests__/depsScan.ts +158 -0
  28. package/src/__tests__/freshObjectDepsGuard.test.ts +144 -0
  29. package/src/components/Chart/Bar/index.tsx +6 -1
  30. package/src/components/Chart/Bubble/index.tsx +6 -1
  31. package/src/components/Chart/Common/index.tsx +6 -1
  32. package/src/components/Chart/Gauge/index.tsx +6 -1
  33. package/src/components/Chart/Heatmap/index.tsx +20 -12
  34. package/src/components/Chart/Line/index.tsx +6 -1
  35. package/src/components/Chart/Pie/Pie3D/index.tsx +12 -3
  36. package/src/components/Chart/Pie/index.tsx +10 -2
  37. package/src/components/Chart/Polar/index.tsx +10 -2
  38. package/src/components/Chart/Radar/index.tsx +6 -1
  39. package/src/components/Chart/optionMemo.test.tsx +232 -0
  40. package/src/hooks/useShallowStable.ts +65 -0
@@ -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
@@ -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);
@@ -0,0 +1,232 @@
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
+ * 这一组用例盯的是 `chartOption` 那个 `useMemo` 的**依赖必须是值,不能是每次渲染
7
+ * 新建的对象**。
8
+ *
9
+ * 依赖里一旦混进新建对象(`...coreOption` 这个 rest 包、或写在解构默认值里的
10
+ * `= { show: false }` / `= ["#fff"]` 字面量),memo 就**恒不命中**:
11
+ * 父组件每渲染一次 → chartOption 换新引用 → effect 重跑 → `setOption(…, true)`
12
+ * 整个重设配置 —— 动画重播、鼠标悬浮态被清掉,用户直接看得见。
13
+ *
14
+ * 修法不是「让 effect 少跑」,而是让依赖回到值语义:
15
+ * - rest 包走 `useShallowStable`(内容浅相等就复用同一个引用);
16
+ * - 解构默认值里的对象/数组字面量提到模块级常量。
17
+ *
18
+ * 所以每个组件都验两条:
19
+ * 1. 父组件重渲染、props 未变 → 不再 setOption;
20
+ * 2. 配置真变了(换数据)→ 仍然要 setOption。
21
+ */
22
+
23
+ const chart = {
24
+ setOption: vi.fn(),
25
+ resize: vi.fn(),
26
+ dispose: vi.fn(),
27
+ getDataURL: vi.fn(() => "data:image/png;base64,AAAA"),
28
+ getOption: vi.fn(() => ({ dataZoom: [{ start: 0, end: 100 }] })),
29
+ on: vi.fn(),
30
+ off: vi.fn(),
31
+ dispatchAction: vi.fn(),
32
+ getZr: vi.fn(() => ({ on: vi.fn(), off: vi.fn() })),
33
+ convertToPixel: vi.fn(() => [0, 0]),
34
+ getWidth: vi.fn(() => 600),
35
+ getHeight: vi.fn(() => 400),
36
+ };
37
+
38
+ vi.mock("echarts", () => ({
39
+ init: () => chart,
40
+ use: () => {},
41
+ registerTheme: () => {},
42
+ graphic: {
43
+ LinearGradient: class {
44
+ constructor(..._args: unknown[]) {}
45
+ },
46
+ },
47
+ }));
48
+ vi.mock("echarts-gl", () => ({}));
49
+
50
+ import ChartBar from "./Bar";
51
+ import ChartLine from "./Line";
52
+ import ChartPie from "./Pie";
53
+ import ChartBubble from "./Bubble";
54
+ import ChartHeatmap from "./Heatmap";
55
+ import ChartPolar from "./Polar";
56
+ import ChartRadar from "./Radar";
57
+ import ChartCommon from "./Common";
58
+ import ChartGauge from "./Gauge";
59
+ import ChartPie3D from "./Pie/Pie3D";
60
+
61
+ // Chart.Bubble 要等 ResizeObserver 报出容器尺寸才会 init,jsdom 的空实现永远不回调,
62
+ // 这里补一个观察即回调的实现
63
+ class FiringResizeObserver {
64
+ constructor(private cb: ResizeObserverCallback) {}
65
+ observe(target: Element) {
66
+ this.cb(
67
+ [
68
+ {
69
+ target,
70
+ contentRect: { width: 600, height: 400 },
71
+ } as unknown as ResizeObserverEntry,
72
+ ],
73
+ this as unknown as ResizeObserver,
74
+ );
75
+ }
76
+ unobserve() {}
77
+ disconnect() {}
78
+ }
79
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver =
80
+ FiringResizeObserver;
81
+
82
+ beforeEach(() => {
83
+ vi.clearAllMocks();
84
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
85
+ width: 600,
86
+ height: 400,
87
+ top: 0,
88
+ left: 0,
89
+ right: 600,
90
+ bottom: 400,
91
+ x: 0,
92
+ y: 0,
93
+ toJSON: () => ({}),
94
+ } as DOMRect);
95
+ });
96
+
97
+ const X_AXIS = ["一", "二", "三", "四"];
98
+ const SERIES = [{ name: "A", value: [1, 2, 3, 4] }];
99
+ const SERIES_2 = [{ name: "A", value: [9, 9, 9, 9] }];
100
+ const PIE_DATA = [{ name: "a", value: 1 }];
101
+ const PIE_DATA_2 = [{ name: "a", value: 2 }];
102
+ const BUBBLE_DATA = [{ name: "a", value: 1 }];
103
+ const BUBBLE_DATA_2 = [{ name: "a", value: 5 }];
104
+ const HEATMAP_DATA: Array<[number, number, number]> = [[0, 0, 1]];
105
+ const HEATMAP_DATA_2: Array<[number, number, number]> = [[0, 0, 7]];
106
+ const RADAR_INDICATORS = [{ name: "a", max: 10 }];
107
+ const RADAR_DATA = [1];
108
+ const RADAR_DATA_2 = [8];
109
+ const POLAR_SERIES = { value: 1, name: "a" };
110
+ const POLAR_SERIES_2 = { value: 8, name: "a" };
111
+ const COMMON_SERIES = [{ type: "bar" as const, data: [1, 2, 3] }];
112
+ const COMMON_SERIES_2 = [{ type: "bar" as const, data: [4, 5, 6] }];
113
+ const GAUGE_DATA = [{ value: 1, name: "a" }];
114
+ const GAUGE_DATA_2 = [{ value: 6, name: "a" }];
115
+ const PIE3D_DATA = [{ name: "a", value: 1 }];
116
+ const PIE3D_DATA_2 = [{ name: "a", value: 4 }];
117
+
118
+ /** 每个组件:[名字, 组件, 稳定 props, 会真正改变配置的 props] */
119
+ const cases: Array<
120
+ [string, React.ComponentType<Record<string, unknown>>, Record<string, unknown>, Record<string, unknown>]
121
+ > = [
122
+ [
123
+ "Chart.Bar",
124
+ ChartBar as React.ComponentType<Record<string, unknown>>,
125
+ { xAxisData: X_AXIS, seriesData: SERIES },
126
+ { seriesData: SERIES_2 },
127
+ ],
128
+ [
129
+ "Chart.Line",
130
+ ChartLine as React.ComponentType<Record<string, unknown>>,
131
+ { xAxisData: X_AXIS, seriesData: SERIES },
132
+ { seriesData: SERIES_2 },
133
+ ],
134
+ [
135
+ "Chart.Pie",
136
+ ChartPie as React.ComponentType<Record<string, unknown>>,
137
+ { seriesData: PIE_DATA },
138
+ { seriesData: PIE_DATA_2 },
139
+ ],
140
+ [
141
+ "Chart.Bubble",
142
+ ChartBubble as React.ComponentType<Record<string, unknown>>,
143
+ { data: BUBBLE_DATA },
144
+ { data: BUBBLE_DATA_2 },
145
+ ],
146
+ [
147
+ "Chart.Heatmap",
148
+ ChartHeatmap as React.ComponentType<Record<string, unknown>>,
149
+ { data: HEATMAP_DATA },
150
+ { data: HEATMAP_DATA_2 },
151
+ ],
152
+ [
153
+ "Chart.Polar",
154
+ ChartPolar as React.ComponentType<Record<string, unknown>>,
155
+ { seriesData: POLAR_SERIES },
156
+ { seriesData: POLAR_SERIES_2 },
157
+ ],
158
+ [
159
+ "Chart.Radar",
160
+ ChartRadar as React.ComponentType<Record<string, unknown>>,
161
+ { indicators: RADAR_INDICATORS, data: RADAR_DATA },
162
+ { data: RADAR_DATA_2 },
163
+ ],
164
+ [
165
+ "Chart.Gauge",
166
+ ChartGauge as React.ComponentType<Record<string, unknown>>,
167
+ { seriesData: GAUGE_DATA },
168
+ { seriesData: GAUGE_DATA_2 },
169
+ ],
170
+ [
171
+ "Chart.Pie.Three",
172
+ ChartPie3D as React.ComponentType<Record<string, unknown>>,
173
+ { pieData: PIE3D_DATA },
174
+ { pieData: PIE3D_DATA_2 },
175
+ ],
176
+ [
177
+ "Chart.Common",
178
+ ChartCommon as React.ComponentType<Record<string, unknown>>,
179
+ { xAxis: { type: "category" as const }, series: COMMON_SERIES },
180
+ { series: COMMON_SERIES_2 },
181
+ ],
182
+ ];
183
+
184
+ describe("chartOption 的 useMemo 依赖必须是值", () => {
185
+ for (const [name, Comp, stableProps, changedProps] of cases) {
186
+ describe(name, () => {
187
+ it("父组件重渲染但配置未变 → 不重设 option", () => {
188
+ let bump: () => void = () => {};
189
+
190
+ function Consumer() {
191
+ const [, setTick] = useState(0);
192
+ bump = () => setTick((n) => n + 1);
193
+
194
+ return <Comp {...stableProps} />;
195
+ }
196
+
197
+ render(<Consumer />);
198
+ const initial = chart.setOption.mock.calls.length;
199
+ expect(initial).toBeGreaterThan(0);
200
+
201
+ act(() => bump());
202
+ act(() => bump());
203
+ act(() => bump());
204
+
205
+ expect(chart.setOption.mock.calls.length).toBe(initial);
206
+ });
207
+
208
+ it("配置真变了 → 仍然重设 option", () => {
209
+ let change: () => void = () => {};
210
+
211
+ function Consumer() {
212
+ const [changed, setChanged] = useState(false);
213
+ change = () => setChanged(true);
214
+
215
+ return (
216
+ <Comp
217
+ {...stableProps}
218
+ {...(changed ? changedProps : {})}
219
+ />
220
+ );
221
+ }
222
+
223
+ render(<Consumer />);
224
+ const initial = chart.setOption.mock.calls.length;
225
+
226
+ act(() => change());
227
+
228
+ expect(chart.setOption.mock.calls.length).toBeGreaterThan(initial);
229
+ });
230
+ });
231
+ }
232
+ });
@@ -0,0 +1,65 @@
1
+ import { useRef } from "react";
2
+
3
+ /**
4
+ * 把一个**内容没变、但每次渲染都新建**的对象收敛成**引用恒定**的对象:
5
+ * 浅比较(自有键 + `Object.is` 比值)相等就继续返回上一次那个引用。
6
+ *
7
+ * 用途只有一个:让它可以直接进 `useMemo` / `useEffect` 的依赖数组。
8
+ *
9
+ * React 的依赖数组按 `Object.is` 比引用,所以依赖里只要混进一个「每次渲染都新建
10
+ * 的对象」,memo 就**恒不命中**、effect 就**每渲染必重跑**。组件内部最容易出现
11
+ * 这种对象的地方是 rest 解构:
12
+ *
13
+ * ```tsx
14
+ * const { className, style, ...coreOption } = props; // coreOption 每次都是新对象
15
+ * const option = useMemo(() => ({ ...coreOption }), [coreOption]); // 永远不命中
16
+ * ```
17
+ *
18
+ * 消费方就算把每一个 prop 都 memo 好,也躲不掉——新对象是组件自己建的。
19
+ * 正确写法是让这个对象回到值语义:
20
+ *
21
+ * ```tsx
22
+ * const { className, style, ...restOption } = props;
23
+ * const coreOption = useShallowStable(restOption); // 内容不变 → 引用不变
24
+ * ```
25
+ *
26
+ * 注意这**不是**「缓存结果跳过副作用」:memo 该重算时照样重算、effect 该重跑时
27
+ * 照样重跑——只要 rest 里任何一个值真的变了,浅比较就不相等,新引用立刻透出去。
28
+ *
29
+ * 渲染期间读写 ref 的安全性:并发渲染下被丢弃的那次渲染可能已经把 ref 换成了它
30
+ * 自己的对象,但那个对象与当次 props 浅相等,所以后续渲染拿到的引用**内容永远
31
+ * 与当前 props 一致**,不会读到过期值。
32
+ */
33
+ export function useShallowStable<T extends object>(value: T): T {
34
+ const ref = useRef(value);
35
+
36
+ if (!shallowEqual(ref.current, value)) {
37
+ ref.current = value;
38
+ }
39
+
40
+ return ref.current;
41
+ }
42
+
43
+ function shallowEqual(a: object, b: object) {
44
+ if (Object.is(a, b)) return true;
45
+
46
+ const aKeys = Object.keys(a);
47
+ const bKeys = Object.keys(b);
48
+ if (aKeys.length !== bKeys.length) return false;
49
+
50
+ for (const key of aKeys) {
51
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
52
+ if (
53
+ !Object.is(
54
+ (a as Record<string, unknown>)[key],
55
+ (b as Record<string, unknown>)[key],
56
+ )
57
+ ) {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ return true;
63
+ }
64
+
65
+ export default useShallowStable;