@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
|
@@ -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,47 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { renderHook } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import { useSelectComposition } from "./useSelectComposition";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 这里的 onSearch 只被当作「要不要监听输入法事件」的真值判断,effect 体从不调它。
|
|
8
|
+
* 它直接进依赖数组,消费方传内联箭头就会每渲染一次拆装一次 window 监听。
|
|
9
|
+
*/
|
|
10
|
+
describe("useSelectComposition", () => {
|
|
11
|
+
let addSpy: ReturnType<typeof vi.spyOn>;
|
|
12
|
+
|
|
13
|
+
const countAdds = () =>
|
|
14
|
+
addSpy.mock.calls.filter(([type]) => type === "compositionstart").length;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
addSpy = vi.spyOn(window, "addEventListener");
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
addSpy.mockRestore();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("没传 onSearch 不注册;从无到有传入时会注册", () => {
|
|
24
|
+
const { rerender } = renderHook(
|
|
25
|
+
({ onSearch }: { onSearch?: (v: string) => void }) =>
|
|
26
|
+
useSelectComposition({ onSearch }),
|
|
27
|
+
{ initialProps: {} as { onSearch?: (v: string) => void } },
|
|
28
|
+
);
|
|
29
|
+
expect(countAdds()).toBe(0);
|
|
30
|
+
|
|
31
|
+
rerender({ onSearch: () => {} });
|
|
32
|
+
expect(countAdds()).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("onSearch 换引用不会反复拆装 window 监听", () => {
|
|
36
|
+
const { rerender } = renderHook(
|
|
37
|
+
({ onSearch }: { onSearch?: (v: string) => void }) =>
|
|
38
|
+
useSelectComposition({ onSearch }),
|
|
39
|
+
{ initialProps: { onSearch: () => {} } },
|
|
40
|
+
);
|
|
41
|
+
expect(countAdds()).toBe(1);
|
|
42
|
+
|
|
43
|
+
rerender({ onSearch: () => {} });
|
|
44
|
+
rerender({ onSearch: () => {} });
|
|
45
|
+
expect(countAdds()).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -11,6 +11,7 @@ export function useSelectComposition({
|
|
|
11
11
|
onSearch,
|
|
12
12
|
}: UseSelectCompositionProps) {
|
|
13
13
|
const [isComposing, setComposing] = useState<boolean>(false);
|
|
14
|
+
const hasOnSearch = !!onSearch;
|
|
14
15
|
|
|
15
16
|
useEffect(() => {
|
|
16
17
|
const compositionend = () => {
|
|
@@ -20,7 +21,7 @@ export function useSelectComposition({
|
|
|
20
21
|
setComposing(true);
|
|
21
22
|
};
|
|
22
23
|
|
|
23
|
-
if (
|
|
24
|
+
if (hasOnSearch) {
|
|
24
25
|
window.addEventListener("compositionstart", compositionstart);
|
|
25
26
|
window.addEventListener("compositionend", compositionend);
|
|
26
27
|
}
|
|
@@ -29,7 +30,10 @@ export function useSelectComposition({
|
|
|
29
30
|
window.removeEventListener("compositionstart", compositionstart);
|
|
30
31
|
window.removeEventListener("compositionend", compositionend);
|
|
31
32
|
};
|
|
32
|
-
|
|
33
|
+
// onSearch 在这里只被当作「要不要监听输入法事件」的真值判断,effect 体从不调它。
|
|
34
|
+
// 直接进依赖数组的话,消费方传内联箭头就会每渲染一次拆一次、装一次 window 监听;
|
|
35
|
+
// 只取「有没有传」这个布尔量,从无到有时照样会重新注册。
|
|
36
|
+
}, [hasOnSearch]);
|
|
33
37
|
|
|
34
38
|
return { isComposing };
|
|
35
39
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { useRef } from "react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
import useAutoScrolling from "./useAutoScrolling";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* onAutoScrollEndAdd 在这个 hook 里身兼两职:既是「有没有加载更多」的开关,
|
|
9
|
+
* 又是真正去加载的动作。整条 rAF 循环挂在它的引用上(loop 依赖它、启停 effect 依赖 loop),
|
|
10
|
+
* 消费方传内联箭头就会每渲染一次重启一次滚动循环 —— 表格滚到一半被打回起点。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DATA = [{ key: "1" }, { key: "2" }];
|
|
14
|
+
|
|
15
|
+
function Host({
|
|
16
|
+
onAutoScrollEndAdd,
|
|
17
|
+
}: {
|
|
18
|
+
onAutoScrollEndAdd?: () => Promise<boolean>;
|
|
19
|
+
}) {
|
|
20
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
useAutoScrolling({
|
|
23
|
+
autoScrolling: true,
|
|
24
|
+
ref,
|
|
25
|
+
dataSource: DATA,
|
|
26
|
+
onAutoScrollEndAdd,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div ref={ref}>
|
|
31
|
+
<div className="ant-table-body">
|
|
32
|
+
<table>
|
|
33
|
+
<tbody className="ant-table-tbody" />
|
|
34
|
+
</table>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("useAutoScrolling", () => {
|
|
41
|
+
let cancelSpy: ReturnType<typeof vi.spyOn>;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
// 让 rAF 不真的跑,cancelAnimationFrame 就只会由 effect 的 cleanup 触发,
|
|
45
|
+
// 于是「循环有没有被重启」可以直接数出来
|
|
46
|
+
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
|
|
47
|
+
() => 1 as unknown as number,
|
|
48
|
+
);
|
|
49
|
+
cancelSpy = vi
|
|
50
|
+
.spyOn(window, "cancelAnimationFrame")
|
|
51
|
+
.mockImplementation(() => {});
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** hook 靠 MutationObserver 把自己标成 ready,这里手动改一次 DOM 触发它 */
|
|
58
|
+
const makeReady = async (container: HTMLElement) => {
|
|
59
|
+
await act(async () => {
|
|
60
|
+
container
|
|
61
|
+
.querySelector(".ant-table-tbody")!
|
|
62
|
+
.appendChild(document.createElement("tr"));
|
|
63
|
+
await Promise.resolve();
|
|
64
|
+
});
|
|
65
|
+
await waitFor(() =>
|
|
66
|
+
expect(window.requestAnimationFrame).toHaveBeenCalled(),
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
it("onAutoScrollEndAdd 换引用不会重启滚动循环", async () => {
|
|
71
|
+
const { container, rerender } = render(
|
|
72
|
+
<Host onAutoScrollEndAdd={() => Promise.resolve(true)} />,
|
|
73
|
+
);
|
|
74
|
+
await makeReady(container);
|
|
75
|
+
|
|
76
|
+
const before = cancelSpy.mock.calls.length;
|
|
77
|
+
|
|
78
|
+
rerender(<Host onAutoScrollEndAdd={() => Promise.resolve(true)} />);
|
|
79
|
+
rerender(<Host onAutoScrollEndAdd={() => Promise.resolve(true)} />);
|
|
80
|
+
|
|
81
|
+
expect(cancelSpy.mock.calls.length).toBe(before);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("从无到有传入 onAutoScrollEndAdd 时会接上加载更多(循环按新开关重建一次)", async () => {
|
|
85
|
+
const { container, rerender } = render(<Host />);
|
|
86
|
+
await makeReady(container);
|
|
87
|
+
|
|
88
|
+
const before = cancelSpy.mock.calls.length;
|
|
89
|
+
|
|
90
|
+
rerender(<Host onAutoScrollEndAdd={() => Promise.resolve(true)} />);
|
|
91
|
+
|
|
92
|
+
// 开关从 false 变 true,循环必须按新语义重建,否则「加载更多」永远接不上
|
|
93
|
+
expect(cancelSpy.mock.calls.length).toBeGreaterThan(before);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useMutationObserver } from "ahooks";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { TableProps } from "..";
|
|
4
|
+
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
4
5
|
|
|
5
6
|
const DEFAULT_INTERVAL = 2000;
|
|
6
7
|
const DEFAULT_SPEED = 25;
|
|
@@ -39,6 +40,13 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
|
|
|
39
40
|
autoScrollingOffset = 0,
|
|
40
41
|
} = props;
|
|
41
42
|
|
|
43
|
+
// onAutoScrollEndAdd 在这里身兼两职:既是「有没有加载更多」的开关,又是真正去加载的动作。
|
|
44
|
+
// 整个 rAF 循环(loop → 依赖它 → 启停 effect 依赖 loop)都挂在它的引用上,
|
|
45
|
+
// 消费方传内联箭头就会每渲染一次重启一次滚动循环 —— 表格滚到一半被打回起点。
|
|
46
|
+
// 拆成两半:开关看布尔量(从无到有传入时仍会正确接上加载更多),动作取 ref 里的最新引用。
|
|
47
|
+
const onAutoScrollEndAddRef = useLatestRef(onAutoScrollEndAdd);
|
|
48
|
+
const hasOnAutoScrollEndAdd = !!onAutoScrollEndAdd;
|
|
49
|
+
|
|
42
50
|
const validSpeed =
|
|
43
51
|
autoScrollingSpeed > 0 ? autoScrollingSpeed : DEFAULT_SPEED;
|
|
44
52
|
const smoothStep = (validSpeed * SMOOTH_SCROLL_TICK) / 1000;
|
|
@@ -324,12 +332,13 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
|
|
|
324
332
|
);
|
|
325
333
|
|
|
326
334
|
const runLoadMore = useCallback(() => {
|
|
327
|
-
|
|
335
|
+
const loadMore = onAutoScrollEndAddRef.current;
|
|
336
|
+
if (!loadMore || pendingLoadMoreRef.current) return;
|
|
328
337
|
pendingLoadMoreRef.current = true;
|
|
329
338
|
phaseRef.current = "loading";
|
|
330
339
|
const token = ++loadTokenRef.current;
|
|
331
340
|
|
|
332
|
-
|
|
341
|
+
loadMore()
|
|
333
342
|
.then((hasMore) => {
|
|
334
343
|
if (token !== loadTokenRef.current) return;
|
|
335
344
|
const currentBody = getBody();
|
|
@@ -352,7 +361,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
|
|
|
352
361
|
if (token !== loadTokenRef.current) return;
|
|
353
362
|
pendingLoadMoreRef.current = false;
|
|
354
363
|
});
|
|
355
|
-
}, [autoScrollLoop, getBody,
|
|
364
|
+
}, [autoScrollLoop, getBody, onAutoScrollEndAddRef]);
|
|
356
365
|
|
|
357
366
|
const loop = useCallback(() => {
|
|
358
367
|
rafRef.current = requestAnimationFrame(loop);
|
|
@@ -391,7 +400,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
|
|
|
391
400
|
// In seamless mode the scroll never actually reaches the bottom; just keep scrolling
|
|
392
401
|
phaseRef.current = "idle";
|
|
393
402
|
} else if (isAtBottom(body)) {
|
|
394
|
-
if (
|
|
403
|
+
if (hasOnAutoScrollEndAdd) {
|
|
395
404
|
runLoadMore();
|
|
396
405
|
} else {
|
|
397
406
|
if (autoScrollLoop) {
|
|
@@ -457,7 +466,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
|
|
|
457
466
|
isAtBottom,
|
|
458
467
|
isBodyScrollable,
|
|
459
468
|
isSeamless,
|
|
460
|
-
|
|
469
|
+
hasOnAutoScrollEndAdd,
|
|
461
470
|
ready,
|
|
462
471
|
ref,
|
|
463
472
|
runLoadMore,
|
|
@@ -506,7 +515,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
|
|
|
506
515
|
return;
|
|
507
516
|
}
|
|
508
517
|
|
|
509
|
-
if (
|
|
518
|
+
if (hasOnAutoScrollEndAdd) {
|
|
510
519
|
// In load-more mode, if scrolling had stopped, resume auto-scrolling when new data arrives
|
|
511
520
|
if (phaseRef.current === "stopped") {
|
|
512
521
|
phaseRef.current = autoScrollMode === "row" ? "wait" : "idle";
|
|
@@ -530,7 +539,7 @@ const useAutoScrolling = (props: UseAutoScrollingProps) => {
|
|
|
530
539
|
interval,
|
|
531
540
|
isBodyScrollable,
|
|
532
541
|
isSeamless,
|
|
533
|
-
|
|
542
|
+
hasOnAutoScrollEndAdd,
|
|
534
543
|
]);
|
|
535
544
|
|
|
536
545
|
useEffect(() => {
|
|
@@ -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;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
import { MemoryRouter } from "react-router-dom";
|
|
5
|
+
|
|
6
|
+
import { useOnlyLvOneMenu } from "./useOnlyLvOneMenu";
|
|
7
|
+
import type { MenuType } from "..";
|
|
8
|
+
|
|
9
|
+
const ITEMS = [
|
|
10
|
+
{ key: "/orch", label: "编排", children: [{ key: "/orch/a", label: "A" }] },
|
|
11
|
+
] as unknown as MenuType[];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 这个 effect 干的是:路由变了就把当前一级菜单的子项交给消费方,并同步展开态。
|
|
15
|
+
* getCurrChildItems 是 effect 体里发出去的通知 —— 它进依赖数组,
|
|
16
|
+
* 配上每次都新建数组的 setOpenkeys([item.key]),就是标准的死循环配方。
|
|
17
|
+
*/
|
|
18
|
+
function Host({ onChildren }: { onChildren: (c: MenuType[]) => void }) {
|
|
19
|
+
const [, setOpenkeys] = useState<string[]>([]);
|
|
20
|
+
const [, setMenuKey] = useState("");
|
|
21
|
+
const [children, setChildren] = useState<MenuType[]>([]);
|
|
22
|
+
|
|
23
|
+
useOnlyLvOneMenu({
|
|
24
|
+
items: ITEMS,
|
|
25
|
+
onlyLvOneMenu: true,
|
|
26
|
+
// 内联箭头 + 每次都是新数组:最坏情况的消费方写法
|
|
27
|
+
getCurrChildItems: (c) => {
|
|
28
|
+
onChildren(c);
|
|
29
|
+
setChildren([...c]);
|
|
30
|
+
},
|
|
31
|
+
setOpenkeys,
|
|
32
|
+
setMenuKey,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return <div data-testid="count">{children.length}</div>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("useOnlyLvOneMenu", () => {
|
|
39
|
+
it("内联 getCurrChildItems 回写 state 不会死循环", () => {
|
|
40
|
+
const spy = vi.fn();
|
|
41
|
+
|
|
42
|
+
const { getByTestId } = render(
|
|
43
|
+
<MemoryRouter
|
|
44
|
+
initialEntries={["/orch/a"]}
|
|
45
|
+
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
|
46
|
+
>
|
|
47
|
+
<Host onChildren={spy} />
|
|
48
|
+
</MemoryRouter>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(getByTestId("count").textContent).toBe("1");
|
|
52
|
+
// 进了循环这里会是几十上百次,直到 React 抛 Maximum update depth exceeded
|
|
53
|
+
expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
|
|
54
|
+
expect(spy).toHaveBeenCalledWith(ITEMS[0].children);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
|
+
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
2
3
|
import { useLocation } from "react-router";
|
|
3
4
|
import { MenuType } from "..";
|
|
4
5
|
|
|
@@ -21,6 +22,11 @@ export const useOnlyLvOneMenu = ({
|
|
|
21
22
|
setMenuKey,
|
|
22
23
|
}: UseOnlyLvOneMenuOptions) => {
|
|
23
24
|
const location = useLocation();
|
|
25
|
+
// getCurrChildItems 是 effect 体里发出去的通知,本身进依赖数组是这一类死循环的标准配方:
|
|
26
|
+
// 消费方传内联箭头 → 每次渲染新引用 → effect 重跑 → setOpenkeys([item.key]) 每次都是
|
|
27
|
+
// 新数组必定触发重渲染 → 又是新引用 …… 直到 React 抛 Maximum update depth exceeded。
|
|
28
|
+
// 通知取 ref 里的最新引用,依赖数组只留真正的输入。
|
|
29
|
+
const getCurrChildItemsRef = useLatestRef(getCurrChildItems);
|
|
24
30
|
|
|
25
31
|
useEffect(() => {
|
|
26
32
|
if (onlyLvOneMenu) {
|
|
@@ -28,14 +34,14 @@ export const useOnlyLvOneMenu = ({
|
|
|
28
34
|
(i) => i.key === "/" + location.pathname.split("/").filter(Boolean)[0]
|
|
29
35
|
);
|
|
30
36
|
if (item) {
|
|
31
|
-
|
|
37
|
+
getCurrChildItemsRef.current?.(item.children || []);
|
|
32
38
|
setOpenkeys([item.key]);
|
|
33
39
|
setMenuKey(item.key);
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
}, [
|
|
37
43
|
items,
|
|
38
|
-
|
|
44
|
+
getCurrChildItemsRef,
|
|
39
45
|
location,
|
|
40
46
|
onlyLvOneMenu,
|
|
41
47
|
setOpenkeys,
|