@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.
- package/es/components/Chart/Bar/index.js +6 -1
- package/es/components/Chart/Bubble/index.js +6 -1
- 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 +9 -2
- package/es/components/Chart/Line/index.js +6 -1
- package/es/components/Chart/Pie/Pie3D/index.js +15 -7
- package/es/components/Chart/Pie/index.js +9 -2
- package/es/components/Chart/Polar/index.js +9 -2
- package/es/components/Chart/Radar/index.js +6 -1
- package/es/components/Checkbox/CheckboxGroup/index.js +4 -1
- package/es/components/FormItem/FormCodeMirror/index.js +3 -1
- package/es/components/FormItem/ItemContainer/index.js +15 -5
- package/es/components/Operate/index.js +4 -1
- package/es/components/Pagination/index.js +3 -1
- package/es/components/Search/_hooks/useSearchCommon.js +4 -1
- package/es/components/Select/AutoCompleteSelect/index.js +3 -1
- package/es/components/Spreadsheet/index.js +8 -2
- package/es/components/Table/_hooks/useTableColumns.js +4 -1
- package/es/components/Tree/_hooks/useTreeState.js +7 -4
- package/es/components/Tree/index.js +8 -2
- package/es/hooks/useShallowStable.d.ts +32 -0
- package/es/hooks/useShallowStable.js +54 -0
- package/lib/components/Chart/Bar/index.js +6 -1
- package/lib/components/Chart/Bubble/index.js +6 -1
- 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 +9 -2
- package/lib/components/Chart/Line/index.js +6 -1
- package/lib/components/Chart/Pie/Pie3D/index.js +15 -7
- package/lib/components/Chart/Pie/index.js +9 -2
- package/lib/components/Chart/Polar/index.js +9 -2
- package/lib/components/Chart/Radar/index.js +6 -1
- package/lib/components/Checkbox/CheckboxGroup/index.js +4 -1
- package/lib/components/FormItem/FormCodeMirror/index.js +3 -1
- package/lib/components/FormItem/ItemContainer/index.js +16 -6
- package/lib/components/Operate/index.js +4 -1
- package/lib/components/Pagination/index.js +3 -1
- package/lib/components/Search/_hooks/useSearchCommon.js +4 -1
- package/lib/components/Select/AutoCompleteSelect/index.js +3 -1
- package/lib/components/Spreadsheet/index.js +8 -2
- package/lib/components/Table/_hooks/useTableColumns.js +4 -1
- package/lib/components/Tree/_hooks/useTreeState.js +6 -3
- package/lib/components/Tree/index.js +8 -2
- package/lib/hooks/useShallowStable.d.ts +32 -0
- package/lib/hooks/useShallowStable.js +59 -0
- package/package.json +1 -1
- package/src/__tests__/callbackDepsGuard.test.ts +4 -51
- package/src/__tests__/depsScan.ts +205 -0
- package/src/__tests__/freshObjectDeps.test.tsx +380 -0
- package/src/__tests__/freshObjectDepsGuard.test.ts +175 -0
- package/src/components/Chart/Bar/index.tsx +6 -1
- package/src/components/Chart/Bubble/index.tsx +6 -1
- 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 +20 -12
- package/src/components/Chart/Line/index.tsx +6 -1
- package/src/components/Chart/Pie/Pie3D/index.tsx +12 -3
- package/src/components/Chart/Pie/index.tsx +10 -2
- package/src/components/Chart/Polar/index.tsx +10 -2
- package/src/components/Chart/Radar/index.tsx +6 -1
- package/src/components/Chart/optionMemo.test.tsx +232 -0
- package/src/components/Checkbox/CheckboxGroup/index.tsx +4 -1
- package/src/components/FormItem/FormCodeMirror/index.tsx +4 -1
- package/src/components/FormItem/ItemContainer/index.tsx +21 -6
- package/src/components/Operate/index.tsx +4 -1
- package/src/components/Pagination/index.tsx +4 -1
- package/src/components/Search/_hooks/useSearchCommon.ts +4 -1
- package/src/components/Select/AutoCompleteSelect/index.tsx +4 -1
- package/src/components/Spreadsheet/depsMemo.test.tsx +95 -0
- package/src/components/Spreadsheet/index.tsx +9 -2
- package/src/components/Table/_hooks/useTableColumns.tsx +4 -1
- package/src/components/Tree/_hooks/useTreeState.ts +14 -8
- package/src/components/Tree/depsMemo.test.tsx +64 -0
- package/src/components/Tree/index.tsx +9 -2
- package/src/hooks/useShallowStable.ts +65 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { fireEvent, render, renderHook, act } from "@testing-library/react";
|
|
2
|
+
import { Form } from "antd";
|
|
3
|
+
import React, { ReactElement, ReactNode } from "react";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 2.5.7:把 2.5.6 守卫钉住的「依赖数组里放每渲染新建对象」欠账逐处清掉后的收益证明。
|
|
8
|
+
*
|
|
9
|
+
* 每处缺陷两条用例:
|
|
10
|
+
* 1. **输入没变时不再重算**;
|
|
11
|
+
* 2. **输入真变时仍正确更新**——比前一条更重要,改坏了比不改糟。
|
|
12
|
+
*
|
|
13
|
+
* 「不再重算」有两种判据,按缺陷形态选:
|
|
14
|
+
*
|
|
15
|
+
* - **对照法**(字面量默认值那一类):同一个组件渲染两次,一次**不传**那个 prop
|
|
16
|
+
* (走解构默认值,修之前每渲染都是新对象),一次传**模块级稳定引用**的等价空值。
|
|
17
|
+
* 两边渲染出的 DOM 完全一样,antd 内部噪声完全抵消,差值只剩「默认值是不是每次
|
|
18
|
+
* 都新建」这一件事。修之前不传那边严格更多,修之后必须**相等**。
|
|
19
|
+
* 次数由下面的 hook 探针统计(包了 `useMemo` / `useEffect`,只数工厂/回调实跑次数)。
|
|
20
|
+
*
|
|
21
|
+
* - **引用法**(rest 包那一类,本来就没有「传稳定引用」的对照):直接断言派生值
|
|
22
|
+
* 在重渲染后**还是同一个引用**,也就是 memo 真的命中了。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const probe = { memo: 0, effect: 0 };
|
|
26
|
+
(globalThis as Record<string, unknown>).__freshDepsProbe = probe;
|
|
27
|
+
|
|
28
|
+
vi.mock("react", async (importOriginal) => {
|
|
29
|
+
const actual = await importOriginal<typeof import("react")>();
|
|
30
|
+
const counters = () =>
|
|
31
|
+
(globalThis as Record<string, unknown>).__freshDepsProbe as typeof probe;
|
|
32
|
+
const useMemo = (fn: () => unknown, deps: unknown[]) =>
|
|
33
|
+
actual.useMemo(() => {
|
|
34
|
+
counters().memo++;
|
|
35
|
+
return fn();
|
|
36
|
+
}, deps);
|
|
37
|
+
const useEffect = (fn: () => unknown, deps: unknown[]) =>
|
|
38
|
+
actual.useEffect(() => {
|
|
39
|
+
counters().effect++;
|
|
40
|
+
return fn() as ReturnType<React.EffectCallback>;
|
|
41
|
+
}, deps);
|
|
42
|
+
// 命名导入与 `React.useMemo(...)` 这两种写法库里都有,两条路都要包上,
|
|
43
|
+
// 否则漏掉的那一半会让用例「怎么改都通过」,等于没测
|
|
44
|
+
return {
|
|
45
|
+
...actual,
|
|
46
|
+
useMemo,
|
|
47
|
+
useEffect,
|
|
48
|
+
default: {
|
|
49
|
+
...(actual as unknown as { default: Record<string, unknown> }).default,
|
|
50
|
+
useMemo,
|
|
51
|
+
useEffect,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
import CheckboxGroup from "../components/Checkbox/CheckboxGroup";
|
|
57
|
+
import Operate from "../components/Operate";
|
|
58
|
+
import Pagination from "../components/Pagination";
|
|
59
|
+
import Select from "../components/Select";
|
|
60
|
+
import Tree from "../components/Tree";
|
|
61
|
+
import ItemContainer from "../components/FormItem/ItemContainer";
|
|
62
|
+
import FormCodeMirror from "../components/FormItem/FormCodeMirror";
|
|
63
|
+
import { useSearchCommon } from "../components/Search/_hooks/useSearchCommon";
|
|
64
|
+
import useTableColumns from "../components/Table/_hooks/useTableColumns";
|
|
65
|
+
|
|
66
|
+
/** 渲染一次再用**同样的 props** 重渲染一次,返回第二次渲染的重算次数 */
|
|
67
|
+
function rerenderCost(make: () => ReactElement) {
|
|
68
|
+
const { rerender, unmount } = render(make());
|
|
69
|
+
const memo0 = probe.memo;
|
|
70
|
+
const effect0 = probe.effect;
|
|
71
|
+
// 必须新建 element:传同一个 element 对象 React 会直接跳过重渲染,测不到东西
|
|
72
|
+
rerender(make());
|
|
73
|
+
const cost = { memo: probe.memo - memo0, effect: probe.effect - effect0 };
|
|
74
|
+
unmount();
|
|
75
|
+
return cost;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
probe.memo = 0;
|
|
80
|
+
probe.effect = 0;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// 对照组用的模块级稳定引用:内容与解构默认值完全一致,只是引用恒定
|
|
84
|
+
const STABLE_EMPTY: never[] = [];
|
|
85
|
+
|
|
86
|
+
/* --------------------------- 字面量默认值:对照法 --------------------------- */
|
|
87
|
+
|
|
88
|
+
describe("Checkbox.Group:options 的默认值不再每渲染新建", () => {
|
|
89
|
+
it("不传 options 时的重算次数 = 传稳定空数组(memo 命中了)", () => {
|
|
90
|
+
const omitted = rerenderCost(() => <CheckboxGroup />);
|
|
91
|
+
const control = rerenderCost(() => <CheckboxGroup options={STABLE_EMPTY} />);
|
|
92
|
+
expect(omitted.memo).toBe(control.memo);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("options 真变了仍然渲染出新选项", () => {
|
|
96
|
+
const { rerender, getByText, queryByText } = render(
|
|
97
|
+
<CheckboxGroup options={[{ label: "甲", value: "a" }]} />,
|
|
98
|
+
);
|
|
99
|
+
expect(getByText("甲")).toBeTruthy();
|
|
100
|
+
rerender(<CheckboxGroup options={[{ label: "乙", value: "b" }]} />);
|
|
101
|
+
expect(queryByText("甲")).toBeNull();
|
|
102
|
+
expect(getByText("乙")).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("Operate:menu 的默认值不再每渲染新建", () => {
|
|
107
|
+
it("不传 menu 时的重算次数 = 传稳定空数组", () => {
|
|
108
|
+
const omitted = rerenderCost(() => <Operate title="编辑" />);
|
|
109
|
+
const control = rerenderCost(() => (
|
|
110
|
+
<Operate title="编辑" menu={STABLE_EMPTY} />
|
|
111
|
+
));
|
|
112
|
+
expect(omitted.memo).toBe(control.memo);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("menu 真变了仍然重新算出可见项", () => {
|
|
116
|
+
const { rerender, getByText, queryByText } = render(
|
|
117
|
+
<Operate title="更多" menu={[{ title: "删除" }]} />,
|
|
118
|
+
);
|
|
119
|
+
expect(getByText("删除")).toBeTruthy();
|
|
120
|
+
rerender(<Operate title="更多" menu={[{ title: "归档" }]} />);
|
|
121
|
+
expect(queryByText("删除")).toBeNull();
|
|
122
|
+
expect(getByText("归档")).toBeTruthy();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("Pagination:pageSizeOptions 的默认值不再每渲染新建", () => {
|
|
127
|
+
it("不传 pageSizeOptions 时的重算次数 = 传稳定空数组", () => {
|
|
128
|
+
const omitted = rerenderCost(() => (
|
|
129
|
+
<Pagination total={100} showSizeChanger />
|
|
130
|
+
));
|
|
131
|
+
const control = rerenderCost(() => (
|
|
132
|
+
<Pagination total={100} showSizeChanger pageSizeOptions={STABLE_EMPTY} />
|
|
133
|
+
));
|
|
134
|
+
expect(omitted.memo).toBe(control.memo);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("pageSizeOptions / pageSize 真变了仍然重新算出档位(当前 pageSize 始终在内)", () => {
|
|
138
|
+
const { rerender, container } = render(
|
|
139
|
+
<Pagination
|
|
140
|
+
total={100}
|
|
141
|
+
showSizeChanger
|
|
142
|
+
pageSize={10}
|
|
143
|
+
pageSizeOptions={[10, 20]}
|
|
144
|
+
/>,
|
|
145
|
+
);
|
|
146
|
+
expect(container.textContent).toContain("10");
|
|
147
|
+
// 换一组档位,且当前 pageSize 不在其中:仍要被补进去(原逻辑)
|
|
148
|
+
rerender(
|
|
149
|
+
<Pagination
|
|
150
|
+
total={100}
|
|
151
|
+
showSizeChanger
|
|
152
|
+
pageSize={15}
|
|
153
|
+
pageSizeOptions={[30, 50]}
|
|
154
|
+
/>,
|
|
155
|
+
);
|
|
156
|
+
expect(container.textContent).toContain("15");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("Select.AutoComplete:options 的默认值不再每渲染新建", () => {
|
|
161
|
+
it("不传 options 时的重算次数 = 传稳定空数组", () => {
|
|
162
|
+
const omitted = rerenderCost(() => <Select.AutoComplete />);
|
|
163
|
+
const control = rerenderCost(() => (
|
|
164
|
+
<Select.AutoComplete options={STABLE_EMPTY} />
|
|
165
|
+
));
|
|
166
|
+
expect(omitted.memo).toBe(control.memo);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("options 真变了仍然重新算出下拉项", () => {
|
|
170
|
+
const { rerender, container } = render(
|
|
171
|
+
<Select.AutoComplete options={[{ value: "alpha" }]} />,
|
|
172
|
+
);
|
|
173
|
+
const input = container.querySelector("input")!;
|
|
174
|
+
fireEvent.mouseDown(input);
|
|
175
|
+
fireEvent.focus(input);
|
|
176
|
+
expect(document.body.textContent).toContain("alpha");
|
|
177
|
+
rerender(<Select.AutoComplete options={[{ value: "beta" }]} />);
|
|
178
|
+
expect(document.body.textContent).toContain("beta");
|
|
179
|
+
expect(document.body.textContent).not.toContain("alpha");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("Tree:treeData 的默认值不再每渲染新建", () => {
|
|
184
|
+
it("不传 treeData 时的重算/重跑次数 = 传稳定空数组", () => {
|
|
185
|
+
const omitted = rerenderCost(() => <Tree defaultExpandLevel={1} />);
|
|
186
|
+
const control = rerenderCost(() => (
|
|
187
|
+
<Tree defaultExpandLevel={1} treeData={STABLE_EMPTY} />
|
|
188
|
+
));
|
|
189
|
+
expect(omitted.memo).toBe(control.memo);
|
|
190
|
+
expect(omitted.effect).toBe(control.effect);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("treeData 真变了仍然渲染出新节点", () => {
|
|
194
|
+
const { rerender, queryByText } = render(
|
|
195
|
+
<Tree treeData={[{ key: "1", value: "1", title: "节点甲" }]} />,
|
|
196
|
+
);
|
|
197
|
+
expect(queryByText("节点甲")).toBeTruthy();
|
|
198
|
+
rerender(<Tree treeData={[{ key: "2", value: "2", title: "节点乙" }]} />);
|
|
199
|
+
expect(queryByText("节点甲")).toBeNull();
|
|
200
|
+
expect(queryByText("节点乙")).toBeTruthy();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("FormCodeMirror:rules 的默认值不再每渲染新建", () => {
|
|
205
|
+
it("不传 rules 时的重算次数 = 传稳定空数组", () => {
|
|
206
|
+
const omitted = rerenderCost(() => (
|
|
207
|
+
<Form>
|
|
208
|
+
<FormCodeMirror name="sql" label="SQL" />
|
|
209
|
+
</Form>
|
|
210
|
+
));
|
|
211
|
+
const control = rerenderCost(() => (
|
|
212
|
+
<Form>
|
|
213
|
+
<FormCodeMirror name="sql" label="SQL" rules={STABLE_EMPTY} />
|
|
214
|
+
</Form>
|
|
215
|
+
));
|
|
216
|
+
expect(omitted.memo).toBe(control.memo);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("rules 真变了仍然并进 mergedRules(必填标记跟着出现)", () => {
|
|
220
|
+
const { container, rerender } = render(
|
|
221
|
+
<Form>
|
|
222
|
+
<FormCodeMirror name="sql" label="SQL" />
|
|
223
|
+
</Form>,
|
|
224
|
+
);
|
|
225
|
+
expect(container.querySelector(".ant-form-item-required")).toBeNull();
|
|
226
|
+
rerender(
|
|
227
|
+
<Form>
|
|
228
|
+
<FormCodeMirror
|
|
229
|
+
name="sql"
|
|
230
|
+
label="SQL"
|
|
231
|
+
rules={[{ required: true, message: "必填" }]}
|
|
232
|
+
/>
|
|
233
|
+
</Form>,
|
|
234
|
+
);
|
|
235
|
+
expect(container.querySelector(".ant-form-item-required")).toBeTruthy();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
/* --------------------------- rest 包 / hook:引用法 -------------------------- */
|
|
240
|
+
|
|
241
|
+
describe("ItemContainer:tips / tipsConfig 不再让 label 的 memo 恒不命中", () => {
|
|
242
|
+
/** 借 labelRender 这个出口把内部算出来的 label 原样捞出来比引用 */
|
|
243
|
+
const makeItem =
|
|
244
|
+
(seen: ReactNode[], props: Record<string, unknown> = {}) =>
|
|
245
|
+
() => (
|
|
246
|
+
<Form>
|
|
247
|
+
<ItemContainer
|
|
248
|
+
label="名称"
|
|
249
|
+
name="name"
|
|
250
|
+
labelRender={(node) => {
|
|
251
|
+
seen.push(node);
|
|
252
|
+
return node;
|
|
253
|
+
}}
|
|
254
|
+
{...props}
|
|
255
|
+
/>
|
|
256
|
+
</Form>
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
it("不传 tips 时,重渲染复用同一份 label", () => {
|
|
260
|
+
const seen: ReactNode[] = [];
|
|
261
|
+
const make = makeItem(seen);
|
|
262
|
+
const { rerender } = render(make());
|
|
263
|
+
rerender(make());
|
|
264
|
+
expect(seen.length).toBeGreaterThanOrEqual(2);
|
|
265
|
+
expect(seen[seen.length - 1]).toBe(seen[0]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("传了 tips(走 tipsConfig 那条分支)时,内容没变也复用同一份 label", () => {
|
|
269
|
+
const seen: ReactNode[] = [];
|
|
270
|
+
// 每次渲染都是新的对象字面量:修之前这让 label 的 memo 恒不命中
|
|
271
|
+
const make = () =>
|
|
272
|
+
makeItem(seen, {
|
|
273
|
+
tips: { title: "说明", icon: "material-symbols:help" },
|
|
274
|
+
})();
|
|
275
|
+
const { rerender } = render(make());
|
|
276
|
+
rerender(make());
|
|
277
|
+
expect(seen.length).toBeGreaterThanOrEqual(2);
|
|
278
|
+
expect(seen[seen.length - 1]).toBe(seen[0]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("label 真变了仍然算出新的 label", () => {
|
|
282
|
+
const seen: ReactNode[] = [];
|
|
283
|
+
const { rerender, getByText } = render(makeItem(seen, { label: "名称" })());
|
|
284
|
+
rerender(makeItem(seen, { label: "标题" })());
|
|
285
|
+
expect(seen[seen.length - 1]).not.toBe(seen[0]);
|
|
286
|
+
expect(getByText("标题")).toBeTruthy();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("tips 内容真变了仍然算出新的 label", () => {
|
|
290
|
+
const seen: ReactNode[] = [];
|
|
291
|
+
const { rerender } = render(
|
|
292
|
+
makeItem(seen, { tips: { title: "说明一" } })(),
|
|
293
|
+
);
|
|
294
|
+
rerender(makeItem(seen, { tips: { title: "说明二" } })());
|
|
295
|
+
expect(seen[seen.length - 1]).not.toBe(seen[0]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const STABLE_SEARCH_ITEMS = [{ label: "名称", name: "name" }];
|
|
300
|
+
|
|
301
|
+
describe("useSearchCommon:moreSearchItems 的默认值不再每渲染新建", () => {
|
|
302
|
+
const useSubject = (moreSearchItems?: unknown[]) => {
|
|
303
|
+
const [form] = Form.useForm();
|
|
304
|
+
return useSearchCommon({
|
|
305
|
+
form,
|
|
306
|
+
searchItems: STABLE_SEARCH_ITEMS,
|
|
307
|
+
...(moreSearchItems ? { moreSearchItems } : {}),
|
|
308
|
+
columnNum: 3,
|
|
309
|
+
autoAdaptWidth: false,
|
|
310
|
+
defaultExpanded: false,
|
|
311
|
+
showAllSearchItems: false,
|
|
312
|
+
} as never);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
it("不传 moreSearchItems 时,重渲染复用同一份派生数组", () => {
|
|
316
|
+
const { result, rerender, unmount } = renderHook(() => useSubject());
|
|
317
|
+
const firstMore = result.current.visibleMoreSearchItems;
|
|
318
|
+
const firstCurrent = result.current.currentSearchItems;
|
|
319
|
+
rerender();
|
|
320
|
+
expect(result.current.visibleMoreSearchItems).toBe(firstMore);
|
|
321
|
+
expect(result.current.currentSearchItems).toBe(firstCurrent);
|
|
322
|
+
unmount();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("moreSearchItems 真变了仍然重新算出展开后的列表", () => {
|
|
326
|
+
const { result, rerender, unmount } = renderHook(
|
|
327
|
+
({ more }: { more: unknown[] }) => useSubject(more),
|
|
328
|
+
{ initialProps: { more: [{ label: "更多甲", name: "a" }] } },
|
|
329
|
+
);
|
|
330
|
+
act(() => result.current.setExpand?.(true));
|
|
331
|
+
const before = result.current.currentSearchItems.length;
|
|
332
|
+
rerender({
|
|
333
|
+
more: [
|
|
334
|
+
{ label: "更多甲", name: "a" },
|
|
335
|
+
{ label: "更多乙", name: "b" },
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
expect(result.current.currentSearchItems.length).toBe(before + 1);
|
|
339
|
+
expect(
|
|
340
|
+
result.current.currentSearchItems.some((item) => item.label === "更多乙"),
|
|
341
|
+
).toBe(true);
|
|
342
|
+
unmount();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("useTableColumns:columns 的默认值不再每渲染新建", () => {
|
|
347
|
+
const baseParams = {
|
|
348
|
+
enhanceColumns: (c?: unknown) => c,
|
|
349
|
+
_pageNum: 1,
|
|
350
|
+
_pageSize: 10,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
it("不传 columns 时,重渲染复用同一份 _columns(cloneDeep 不再空跑)", () => {
|
|
354
|
+
const { result, rerender, unmount } = renderHook(() =>
|
|
355
|
+
useTableColumns(baseParams as never),
|
|
356
|
+
);
|
|
357
|
+
const first = result.current._columns;
|
|
358
|
+
rerender();
|
|
359
|
+
expect(result.current._columns).toBe(first);
|
|
360
|
+
unmount();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("columns 真变了仍然重新算出列", () => {
|
|
364
|
+
const { result, rerender, unmount } = renderHook(
|
|
365
|
+
({ columns }: { columns: unknown[] }) =>
|
|
366
|
+
useTableColumns({ ...baseParams, columns } as never),
|
|
367
|
+
{ initialProps: { columns: [{ title: "甲", dataIndex: "a" }] } },
|
|
368
|
+
);
|
|
369
|
+
expect(result.current._columns[0].title).toBe("甲");
|
|
370
|
+
rerender({
|
|
371
|
+
columns: [
|
|
372
|
+
{ title: "甲", dataIndex: "a" },
|
|
373
|
+
{ title: "乙", dataIndex: "b" },
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
expect(result.current._columns).toHaveLength(2);
|
|
377
|
+
expect(result.current._columns[1].title).toBe("乙");
|
|
378
|
+
unmount();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
freshObjectBindings,
|
|
6
|
+
hookDeps,
|
|
7
|
+
stripComments,
|
|
8
|
+
walkSources,
|
|
9
|
+
} from "./depsScan";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 防回归守卫:**每次渲染都新建的对象不许进 hook 依赖数组**。
|
|
13
|
+
*
|
|
14
|
+
* React 的依赖数组按 `Object.is` 比引用。依赖里只要有一个「组件自己每次渲染新建
|
|
15
|
+
* 的对象」,`useMemo` 就**恒不命中**、`useEffect` 就**每渲染必重跑**。
|
|
16
|
+
* 组件内部最常见的两个来源:
|
|
17
|
+
*
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const { className, ...coreOption } = props; // rest 包,每次都是新对象
|
|
20
|
+
* const { inRangeColor = ["#fff"] } = props; // 字面量默认值,不传时每次都是新数组
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* 这条缺陷和回调那条一样没有任何信号:不报错、不崩,只是图表每渲染一次就
|
|
24
|
+
* `setOption(notMerge)` 整个重设——动画重播、鼠标悬浮态被清掉,人工 review 拦不住。
|
|
25
|
+
*
|
|
26
|
+
* 正确写法:
|
|
27
|
+
* - rest 包 → `useShallowStable(rest)`(见 `src/hooks/useShallowStable.ts`);
|
|
28
|
+
* - 字面量默认值 → 提到模块级常量。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SRC = path.resolve(__dirname, "..");
|
|
32
|
+
const HOOK_RE = /use(?:Memo|Callback|(?:Layout)?Effect)\(/g;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 正式白名单:`文件相对路径::依赖名` → `{ raw, why }`。
|
|
36
|
+
*
|
|
37
|
+
* 只登记**经过逐处判断、确认不是缺陷**的写法,且必须精确到依赖原文 `raw`:
|
|
38
|
+
* 同一个绑定换一种写法(比如从 `x.open` 变成裸 `x`)就不再匹配,会照常失败。
|
|
39
|
+
* 这样白名单不会变成整文件整绑定的免死金牌。
|
|
40
|
+
*/
|
|
41
|
+
const ALLOWED: Record<string, { raw: string; why: string }> = {
|
|
42
|
+
"components/Panel/ListPanel/ListModalPanel/index.tsx::modalConfig": {
|
|
43
|
+
raw: "modalConfig.open",
|
|
44
|
+
why: "依赖取的是 modalConfig.open 这个布尔值,不是那个新对象本身;布尔值按值比较,effect 只在弹窗真的开合时重跑,不存在恒不命中",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 存量欠账:守卫上线(2.5.6)时就已经存在的命中。2.5.7 已逐处清完,名单为空。
|
|
50
|
+
*
|
|
51
|
+
* 这份名单**只许变短**——下面「只许变短」那条用例会在某项修好却忘了删时报错。
|
|
52
|
+
* 新写的代码一律直接失败,不许往这里加。
|
|
53
|
+
*/
|
|
54
|
+
const KNOWN_DEBT: string[] = [];
|
|
55
|
+
|
|
56
|
+
interface Hit {
|
|
57
|
+
/** `文件相对路径::依赖名` */
|
|
58
|
+
key: string;
|
|
59
|
+
file: string;
|
|
60
|
+
line: number;
|
|
61
|
+
/** 依赖数组里的原文(`x` 还是 `x.open`),白名单按它精确匹配 */
|
|
62
|
+
raw: string;
|
|
63
|
+
why: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scan(): Hit[] {
|
|
67
|
+
const hits: Hit[] = [];
|
|
68
|
+
|
|
69
|
+
for (const file of walkSources(SRC)) {
|
|
70
|
+
const rel = path.relative(SRC, file).split(path.sep).join("/");
|
|
71
|
+
const src = stripComments(fs.readFileSync(file, "utf8"));
|
|
72
|
+
const fresh = freshObjectBindings(src);
|
|
73
|
+
if (!fresh.size) continue;
|
|
74
|
+
|
|
75
|
+
for (const [line, dep, raw] of hookDeps(src, HOOK_RE)) {
|
|
76
|
+
const why = fresh.get(dep);
|
|
77
|
+
if (!why) continue;
|
|
78
|
+
hits.push({ key: `${rel}::${dep}`, file: rel, line, raw, why });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return hits;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("每次渲染都新建的对象不进 hook 依赖数组", () => {
|
|
86
|
+
it("全库无未登记的新命中", () => {
|
|
87
|
+
const offenders = scan()
|
|
88
|
+
.filter(({ key, raw }) => ALLOWED[key]?.raw !== raw && !KNOWN_DEBT.includes(key))
|
|
89
|
+
.map(({ file, line, why }) => `${file}:${line} ${why}`);
|
|
90
|
+
|
|
91
|
+
expect(offenders).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("白名单不许留过期条目(对应写法改掉了就删掉它)", () => {
|
|
95
|
+
const live = new Set(scan().map(({ key, raw }) => `${key}::${raw}`));
|
|
96
|
+
expect(
|
|
97
|
+
Object.entries(ALLOWED)
|
|
98
|
+
.filter(([key, { raw }]) => !live.has(`${key}::${raw}`))
|
|
99
|
+
.map(([key]) => key),
|
|
100
|
+
).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("Chart 一族一条都不剩(2.5.6 修的就是它)", () => {
|
|
104
|
+
const chartHits = scan()
|
|
105
|
+
.filter(({ file }) => file.startsWith("components/Chart/"))
|
|
106
|
+
.map(({ key, line }) => `${key}@${line}`);
|
|
107
|
+
|
|
108
|
+
expect(chartHits).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("存量欠账名单只许变短(2.5.7 起应恒为空)", () => {
|
|
112
|
+
const live = new Set(scan().map(({ key }) => key));
|
|
113
|
+
// 修好了就把它从 KNOWN_DEBT 里删掉,别留着已经不存在的条目
|
|
114
|
+
expect(KNOWN_DEBT.filter((key) => !live.has(key))).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("守卫本身能抓到问题(自测)", () => {
|
|
118
|
+
const restCase = `
|
|
119
|
+
const { className, ...coreOption } = props;
|
|
120
|
+
const option = useMemo(() => ({ ...coreOption }), [coreOption]);
|
|
121
|
+
`;
|
|
122
|
+
expect(freshObjectBindings(restCase).has("coreOption")).toBe(true);
|
|
123
|
+
expect(hookDeps(restCase, HOOK_RE).map(([, d]) => d)).toContain(
|
|
124
|
+
"coreOption",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const defaultCase = `
|
|
128
|
+
const { colors = ["#fff"], size = { w: 1 }, name = "x" } = props;
|
|
129
|
+
useEffect(() => {}, [colors, size, name]);
|
|
130
|
+
`;
|
|
131
|
+
const fresh = freshObjectBindings(defaultCase);
|
|
132
|
+
expect(fresh.has("colors")).toBe(true);
|
|
133
|
+
expect(fresh.has("size")).toBe(true);
|
|
134
|
+
// 原始值默认值不是新对象,不该被误判
|
|
135
|
+
expect(fresh.has("name")).toBe(false);
|
|
136
|
+
|
|
137
|
+
// 稳定引用(useShallowStable 的返回值 / 模块级常量)不该被误判
|
|
138
|
+
const fixed = `
|
|
139
|
+
const { className, ...restOption } = props;
|
|
140
|
+
const coreOption = useShallowStable(restOption);
|
|
141
|
+
const option = useMemo(() => ({ ...coreOption }), [coreOption]);
|
|
142
|
+
`;
|
|
143
|
+
expect(freshObjectBindings(fixed).has("coreOption")).toBe(false);
|
|
144
|
+
|
|
145
|
+
// 回调体里出现过 `, [`(JSX 的 classNames 常写成这样)时,依赖数组仍要切对:
|
|
146
|
+
// 旧的贪婪正则会从最左那个 `, [` 起一路吞到结尾,把真命中静默漏掉
|
|
147
|
+
const jsxCase = `
|
|
148
|
+
const { colors = ["#fff"] } = props;
|
|
149
|
+
const node = useMemo(() => (
|
|
150
|
+
<span className={classNames(styles.a, { [styles.b]: on })} />
|
|
151
|
+
), [colors, on]);
|
|
152
|
+
`;
|
|
153
|
+
expect(hookDeps(jsxCase, HOOK_RE).map(([, d]) => d)).toEqual([
|
|
154
|
+
"colors",
|
|
155
|
+
"on",
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
// 依赖原文要原样带出来,白名单才能按 `x` / `x.open` 精确区分
|
|
159
|
+
expect(
|
|
160
|
+
hookDeps(`useEffect(() => {}, [cfg.open, cfg]);`, HOOK_RE).map(
|
|
161
|
+
([, , raw]) => raw,
|
|
162
|
+
),
|
|
163
|
+
).toEqual(["cfg.open", "cfg"]);
|
|
164
|
+
|
|
165
|
+
// 最后一个实参不是依赖数组时不算依赖(例如 `useMemo(() => [1, 2])`)
|
|
166
|
+
expect(hookDeps(`const a = useMemo(() => [x, y]);`, HOOK_RE)).toEqual([]);
|
|
167
|
+
|
|
168
|
+
// 注释里的示例代码不算命中
|
|
169
|
+
expect(
|
|
170
|
+
freshObjectBindings(
|
|
171
|
+
stripComments(`/** const { ...coreOption } = props; */\nconst a = 1;`),
|
|
172
|
+
).size,
|
|
173
|
+
).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -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 ChartBarProps extends ChartCommonProps {
|
|
30
31
|
chartTitle?: string;
|
|
@@ -70,8 +71,12 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
|
|
|
70
71
|
enableLegendAutoScroll = false,
|
|
71
72
|
legendVisibleCount = 8,
|
|
72
73
|
legendScrollInterval = 1500,
|
|
73
|
-
...
|
|
74
|
+
...restOption
|
|
74
75
|
} = props;
|
|
76
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
77
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
78
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
79
|
+
const coreOption = useShallowStable(restOption);
|
|
75
80
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
76
81
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
77
82
|
// Defers `echarts.init` until the container has a box — see the hook for why
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
drawCircles,
|
|
14
14
|
} from "../_utils/bubble";
|
|
15
15
|
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
16
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
16
17
|
|
|
17
18
|
export interface BubbleDataItem {
|
|
18
19
|
name: string;
|
|
@@ -71,8 +72,12 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
|
|
|
71
72
|
title,
|
|
72
73
|
onChart,
|
|
73
74
|
onClick,
|
|
74
|
-
...
|
|
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
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
|
@@ -4,9 +4,14 @@ import styles from "../index.module.scss";
|
|
|
4
4
|
import { ChartCommonProps, ChartOptionType, ChartsOption } from "..";
|
|
5
5
|
import * as echarts from "echarts";
|
|
6
6
|
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
7
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
7
8
|
|
|
8
9
|
const Common: React.FC<ChartCommonProps> = (props) => {
|
|
9
|
-
const { className, style, onChart, ...
|
|
10
|
+
const { className, style, onChart, ...restOption } = props;
|
|
11
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
12
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
13
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
14
|
+
const coreOption = useShallowStable(restOption);
|
|
10
15
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
11
16
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
12
17
|
// Defers `echarts.init` until the container has a box — see the hook for why
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|
11
11
|
|
|
12
12
|
import styles from "../index.module.scss";
|
|
13
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
13
14
|
|
|
14
15
|
export interface ChartGaugeProps extends ChartCommonProps {
|
|
15
16
|
color?: string[] | string;
|
|
@@ -28,8 +29,12 @@ const Gauge: React.FC<ChartGaugeProps> = (props) => {
|
|
|
28
29
|
axisLabel,
|
|
29
30
|
title,
|
|
30
31
|
color = "#1675FB",
|
|
31
|
-
...
|
|
32
|
+
...restSeries
|
|
32
33
|
} = series as echarts.GaugeSeriesOption;
|
|
34
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
35
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
36
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
37
|
+
const coreSeries = useShallowStable(restSeries);
|
|
33
38
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
34
39
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
35
40
|
// Defers `echarts.init` until the container has a box — see the hook for why
|