@hsu-react/ui 2.5.5 → 2.5.7

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 (76) 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/components/Checkbox/CheckboxGroup/index.js +4 -1
  12. package/es/components/FormItem/FormCodeMirror/index.js +3 -1
  13. package/es/components/FormItem/ItemContainer/index.js +15 -5
  14. package/es/components/Operate/index.js +4 -1
  15. package/es/components/Pagination/index.js +3 -1
  16. package/es/components/Search/_hooks/useSearchCommon.js +4 -1
  17. package/es/components/Select/AutoCompleteSelect/index.js +3 -1
  18. package/es/components/Spreadsheet/index.js +8 -2
  19. package/es/components/Table/_hooks/useTableColumns.js +4 -1
  20. package/es/components/Tree/_hooks/useTreeState.js +7 -4
  21. package/es/components/Tree/index.js +8 -2
  22. package/es/hooks/useShallowStable.d.ts +32 -0
  23. package/es/hooks/useShallowStable.js +54 -0
  24. package/lib/components/Chart/Bar/index.js +6 -1
  25. package/lib/components/Chart/Bubble/index.js +6 -1
  26. package/lib/components/Chart/Common/index.js +6 -1
  27. package/lib/components/Chart/Gauge/index.js +6 -1
  28. package/lib/components/Chart/Heatmap/index.js +9 -2
  29. package/lib/components/Chart/Line/index.js +6 -1
  30. package/lib/components/Chart/Pie/Pie3D/index.js +15 -7
  31. package/lib/components/Chart/Pie/index.js +9 -2
  32. package/lib/components/Chart/Polar/index.js +9 -2
  33. package/lib/components/Chart/Radar/index.js +6 -1
  34. package/lib/components/Checkbox/CheckboxGroup/index.js +4 -1
  35. package/lib/components/FormItem/FormCodeMirror/index.js +3 -1
  36. package/lib/components/FormItem/ItemContainer/index.js +16 -6
  37. package/lib/components/Operate/index.js +4 -1
  38. package/lib/components/Pagination/index.js +3 -1
  39. package/lib/components/Search/_hooks/useSearchCommon.js +4 -1
  40. package/lib/components/Select/AutoCompleteSelect/index.js +3 -1
  41. package/lib/components/Spreadsheet/index.js +8 -2
  42. package/lib/components/Table/_hooks/useTableColumns.js +4 -1
  43. package/lib/components/Tree/_hooks/useTreeState.js +6 -3
  44. package/lib/components/Tree/index.js +8 -2
  45. package/lib/hooks/useShallowStable.d.ts +32 -0
  46. package/lib/hooks/useShallowStable.js +59 -0
  47. package/package.json +1 -1
  48. package/src/__tests__/callbackDepsGuard.test.ts +4 -51
  49. package/src/__tests__/depsScan.ts +205 -0
  50. package/src/__tests__/freshObjectDeps.test.tsx +380 -0
  51. package/src/__tests__/freshObjectDepsGuard.test.ts +175 -0
  52. package/src/components/Chart/Bar/index.tsx +6 -1
  53. package/src/components/Chart/Bubble/index.tsx +6 -1
  54. package/src/components/Chart/Common/index.tsx +6 -1
  55. package/src/components/Chart/Gauge/index.tsx +6 -1
  56. package/src/components/Chart/Heatmap/index.tsx +20 -12
  57. package/src/components/Chart/Line/index.tsx +6 -1
  58. package/src/components/Chart/Pie/Pie3D/index.tsx +12 -3
  59. package/src/components/Chart/Pie/index.tsx +10 -2
  60. package/src/components/Chart/Polar/index.tsx +10 -2
  61. package/src/components/Chart/Radar/index.tsx +6 -1
  62. package/src/components/Chart/optionMemo.test.tsx +232 -0
  63. package/src/components/Checkbox/CheckboxGroup/index.tsx +4 -1
  64. package/src/components/FormItem/FormCodeMirror/index.tsx +4 -1
  65. package/src/components/FormItem/ItemContainer/index.tsx +21 -6
  66. package/src/components/Operate/index.tsx +4 -1
  67. package/src/components/Pagination/index.tsx +4 -1
  68. package/src/components/Search/_hooks/useSearchCommon.ts +4 -1
  69. package/src/components/Select/AutoCompleteSelect/index.tsx +4 -1
  70. package/src/components/Spreadsheet/depsMemo.test.tsx +95 -0
  71. package/src/components/Spreadsheet/index.tsx +9 -2
  72. package/src/components/Table/_hooks/useTableColumns.tsx +4 -1
  73. package/src/components/Tree/_hooks/useTreeState.ts +14 -8
  74. package/src/components/Tree/depsMemo.test.tsx +64 -0
  75. package/src/components/Tree/index.tsx +9 -2
  76. package/src/hooks/useShallowStable.ts +65 -0
@@ -10,6 +10,7 @@ import {
10
10
  import { resolveChartChrome } from "../_utils/chartTheme";
11
11
  import useIsDark from "../../../hooks/useIsDark";
12
12
  import { useLatestRef } from "../../../hooks/useLatestRef";
13
+ import useShallowStable from "../../../hooks/useShallowStable";
13
14
 
14
15
  export interface HeatmapDataItem {
15
16
  /** X-axis index or name */
@@ -43,6 +44,19 @@ export interface ChartHeatmapProps extends ChartCommonProps {
43
44
  inRangeColor?: string[];
44
45
  }
45
46
 
47
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
48
+ const DEFAULT_IN_RANGE_COLOR = [
49
+ "#8BCCFB",
50
+ "#75C3FB",
51
+ "#5AB5F6",
52
+ "#42AEFA",
53
+ "#2DA1F5",
54
+ "#0F89E1",
55
+ "#056FBB",
56
+ "#025794",
57
+ "#0A4089",
58
+ ];
59
+
46
60
  const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
47
61
  const {
48
62
  className,
@@ -61,19 +75,13 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
61
75
  onChart,
62
76
  onClick,
63
77
  zeroColor,
64
- inRangeColor = [
65
- "#8BCCFB",
66
- "#75C3FB",
67
- "#5AB5F6",
68
- "#42AEFA",
69
- "#2DA1F5",
70
- "#0F89E1",
71
- "#056FBB",
72
- "#025794",
73
- "#0A4089",
74
- ],
75
- ...coreOption
78
+ inRangeColor = DEFAULT_IN_RANGE_COLOR,
79
+ ...restOption
76
80
  } = props;
81
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
82
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
83
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
84
+ const coreOption = useShallowStable(restOption);
77
85
  const chartRef = useRef<HTMLDivElement>(null);
78
86
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
79
87
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -25,6 +25,7 @@ import {
25
25
  import { resolveChartChrome } from "../_utils/chartTheme";
26
26
  import useIsDark from "../../../hooks/useIsDark";
27
27
  import useLatestRef from "../../../hooks/useLatestRef";
28
+ import useShallowStable from "../../../hooks/useShallowStable";
28
29
 
29
30
  export interface ChartLineProps extends ChartCommonProps {
30
31
  chartTitle?: string;
@@ -71,8 +72,12 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
71
72
  enableLegendAutoScroll = false,
72
73
  legendVisibleCount = 8,
73
74
  legendScrollInterval = 1500,
74
- ...coreOption
75
+ ...restOption
75
76
  } = props;
77
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
78
+ // 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
79
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
80
+ const coreOption = useShallowStable(restOption);
76
81
  const chartRef = useRef<HTMLDivElement>(null);
77
82
  const chartInstanceRef = useRef<echarts.ECharts | null>(null);
78
83
  // Defers `echarts.init` until the container has a box — see the hook for why
@@ -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);
@@ -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
+ });
@@ -9,6 +9,9 @@ import styles from "./index.module.scss";
9
9
 
10
10
  const Group = Checkbox.Group;
11
11
 
12
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
13
+ const EMPTY_OPTIONS: AntdCheckboxGroupProps["options"] = [];
14
+
12
15
  export interface CheckboxGroupProps extends AntdCheckboxGroupProps {
13
16
  outline?: boolean;
14
17
  hasAll?: boolean;
@@ -21,7 +24,7 @@ const CheckboxGroup: React.FC<CheckboxGroupProps> = (props) => {
21
24
  className,
22
25
  outline,
23
26
  hasAll,
24
- options = [],
27
+ options = EMPTY_OPTIONS,
25
28
  value,
26
29
  onChange,
27
30
  layout = "horizontal",
@@ -7,12 +7,15 @@ export interface FormCodeMirrorProps extends ItemContainerProps {
7
7
  componentProps?: CodeMirrorProps;
8
8
  }
9
9
 
10
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
11
+ const EMPTY_RULES: ItemContainerProps["rules"] = [];
12
+
10
13
  const FormCodeMirror: React.FC<FormCodeMirrorProps> = (props) => {
11
14
  const {
12
15
  componentProps = {},
13
16
  className: itemClassName,
14
17
  disabled,
15
- rules = [],
18
+ rules = EMPTY_RULES,
16
19
  name,
17
20
  ...formItemProps
18
21
  } = props;
@@ -9,6 +9,7 @@ import usePermissions from "../../../hooks/usePermissions";
9
9
  import { isLegacyHasSelectorBrowser } from "../../../utils/cssSupports";
10
10
  import { generateRandomStr } from "hsu-utils";
11
11
  import useLabelSize from "./_hooks/useLabelSize";
12
+ import useShallowStable from "../../../hooks/useShallowStable";
12
13
  import useInputSize from "./_hooks/useInputSize";
13
14
 
14
15
  interface TipsProps {
@@ -50,6 +51,9 @@ export interface ItemContainerProps extends FormItemProps {
50
51
  visible?: boolean;
51
52
  }
52
53
 
54
+ // 解构默认值写成字面量会每次渲染新建一个对象,进依赖数组就让 memo 恒不命中;提到模块级常量
55
+ const EMPTY_TIPS: NonNullable<ItemContainerProps["tips"]> = {};
56
+
53
57
  const ItemContainer: React.FC<ItemContainerProps> = (props) => {
54
58
  const {
55
59
  children,
@@ -73,7 +77,7 @@ const ItemContainer: React.FC<ItemContainerProps> = (props) => {
73
77
  colon,
74
78
  label,
75
79
  labelRender,
76
- tips = {},
80
+ tips = EMPTY_TIPS,
77
81
  horizontalAlignment = "start",
78
82
  hideRequired = false,
79
83
  labelClassName,
@@ -89,7 +93,18 @@ const ItemContainer: React.FC<ItemContainerProps> = (props) => {
89
93
  visible: _visible,
90
94
  ...formItemProps
91
95
  } = props;
92
- const { icon = "material-symbols:help", iconClassName, ...tipsConfig } = tips;
96
+ const {
97
+ icon = "material-symbols:help",
98
+ iconClassName,
99
+ ...restTipsConfig
100
+ } = tips;
101
+ // rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 memo 恒不命中。
102
+ // useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
103
+ const tipsConfig = useShallowStable(restTipsConfig);
104
+ // memo 只需要知道「有没有配 tips」,把对象本身放进依赖数组等于按引用比:
105
+ // 消费方写内联 `tips={{ ... }}`(最常见的写法)就让 label 的 memo 恒不命中。
106
+ // 拆成布尔量后依赖回到值语义。
107
+ const hasTips = Object.keys(tips).length > 0;
93
108
  const { permitted } = usePermissions(hasPermi);
94
109
  const cls = useMemo(() => generateRandomStr(10), []);
95
110
  const legacyHasSelector = isLegacyHasSelectorBrowser();
@@ -99,7 +114,7 @@ const ItemContainer: React.FC<ItemContainerProps> = (props) => {
99
114
  return undefined;
100
115
  }
101
116
 
102
- return Object.keys(tips).length ? (
117
+ return hasTips ? (
103
118
  <>
104
119
  <span className={classNames(styles.labelContent, labelClassName)}>
105
120
  <span
@@ -160,7 +175,7 @@ const ItemContainer: React.FC<ItemContainerProps> = (props) => {
160
175
  </>
161
176
  ) : undefined;
162
177
  }, [
163
- tips,
178
+ hasTips,
164
179
  labelClassName,
165
180
  label,
166
181
  tipsConfig,
@@ -225,7 +240,7 @@ const ItemContainer: React.FC<ItemContainerProps> = (props) => {
225
240
  [styles.hideRequired]: hideRequired,
226
241
  [styles[horizontalAlignment]]:
227
242
  layout === "horizontal" && horizontalAlignment,
228
- [styles.hasLabelContent]: !!Object.keys(tips).length,
243
+ [styles.hasLabelContent]: hasTips,
229
244
  [styles.hideAdditiona]: hideAdditiona,
230
245
  [styles.legacyHasLabelExtra]:
231
246
  legacyHasSelector &&
@@ -233,7 +248,7 @@ const ItemContainer: React.FC<ItemContainerProps> = (props) => {
233
248
  !hideLabel &&
234
249
  !!label &&
235
250
  !!labelExtra &&
236
- !Object.keys(tips).length,
251
+ !hasTips,
237
252
  })}
238
253
  colon={
239
254
  typeof colon === "boolean"
@@ -28,6 +28,9 @@ export interface OperateProps
28
28
  moreIcon?: ReactNode | false;
29
29
  }
30
30
 
31
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
32
+ const EMPTY_MENU: OperateProps[] = [];
33
+
31
34
  /**
32
35
  * Render an icon
33
36
  * @param icon - Icon configuration
@@ -56,7 +59,7 @@ const Operate: React.FC<OperateProps> = (props) => {
56
59
  const {
57
60
  title,
58
61
  className,
59
- menu = [],
62
+ menu = EMPTY_MENU,
60
63
  icon,
61
64
  delete: isDelete,
62
65
  popconfirm,
@@ -20,10 +20,13 @@ export interface PaginationProps
20
20
  onStaticPaginationChange?: (page: number, pageSize: number) => void;
21
21
  }
22
22
 
23
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
24
+ const EMPTY_PAGE_SIZE_OPTIONS: number[] = [];
25
+
23
26
  const Pagination: React.FC<PaginationProps> = (props) => {
24
27
  const {
25
28
  simple,
26
- pageSizeOptions = [],
29
+ pageSizeOptions = EMPTY_PAGE_SIZE_OPTIONS,
27
30
  showSizeChanger,
28
31
  onShowSizeChange,
29
32
  onChange,
@@ -76,6 +76,9 @@ export interface UseSearchCommonReturn {
76
76
  permitted: boolean;
77
77
  }
78
78
 
79
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
80
+ const EMPTY_SEARCH_ITEMS: FormItemProps[] = [];
81
+
79
82
  /**
80
83
  * Extracts the common logic shared by all Search components
81
84
  */
@@ -85,7 +88,7 @@ export const useSearchCommon = (
85
88
  const {
86
89
  form,
87
90
  searchItems,
88
- moreSearchItems = [],
91
+ moreSearchItems = EMPTY_SEARCH_ITEMS,
89
92
  searchData,
90
93
  hasPermi,
91
94
  beforeButtonGroup,
@@ -40,6 +40,9 @@ export interface AutoCompleteSelectProps
40
40
  optionFontSize?: number;
41
41
  }
42
42
 
43
+ // 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
44
+ const EMPTY_OPTIONS: AutoCompleteOption[] = [];
45
+
43
46
  const AutoCompleteSelect: React.FC<AutoCompleteSelectProps> = (props) => {
44
47
  const {
45
48
  prefix,
@@ -53,7 +56,7 @@ const AutoCompleteSelect: React.FC<AutoCompleteSelectProps> = (props) => {
53
56
  popupClassName,
54
57
  filterOption: customFilterOption,
55
58
  onChange,
56
- options = [],
59
+ options = EMPTY_OPTIONS,
57
60
  placement = "bottomLeft",
58
61
  onDropdownVisibleChange,
59
62
  popupMatchSelectWidth,