@hsu-react/ui 2.5.6 → 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/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/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/package.json +1 -1
- package/src/__tests__/depsScan.ts +62 -15
- package/src/__tests__/freshObjectDeps.test.tsx +380 -0
- package/src/__tests__/freshObjectDepsGuard.test.ts +54 -23
- 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
|
@@ -64,52 +64,99 @@ export function walkSources(dir: string, out: string[] = []) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* 找出每个匹配 `hookRe` 的 hook 调用的依赖数组,返回 `[行号,
|
|
67
|
+
* 找出每个匹配 `hookRe` 的 hook 调用的依赖数组,返回 `[行号, 依赖名, 依赖原文]`。
|
|
68
68
|
* `hookRe` 必须带 `g`,且以 `\(` 结尾(例如 `/use(?:Layout)?Effect\(/g`)。
|
|
69
|
+
*
|
|
70
|
+
* 依赖数组按「调用参数里最后一个顶层 `[...]`,且它后面到 `)` 之间只剩逗号/空白」
|
|
71
|
+
* 来定位。早先用 `/,\s*\[([\s\S]*)\]\s*$/` 正则从**最左**的 `, [` 起贪婪匹配,
|
|
72
|
+
* 回调体里只要出现一次 `, [`(JSX 的 `classNames(x, { [styles.a]: b })` 就会),
|
|
73
|
+
* 真正的依赖数组就被并进同一段,切出来的是带换行的 JSX 碎片而不是标识符 ——
|
|
74
|
+
* 结果是真命中被静默漏掉。这里改成括号配平扫描,不再漏。
|
|
69
75
|
*/
|
|
70
76
|
export function hookDeps(
|
|
71
77
|
src: string,
|
|
72
78
|
hookRe: RegExp,
|
|
73
|
-
): Array<[number, string]> {
|
|
74
|
-
const found: Array<[number, string]> = [];
|
|
79
|
+
): Array<[number, string, string]> {
|
|
80
|
+
const found: Array<[number, string, string]> = [];
|
|
75
81
|
const re = new RegExp(hookRe.source, "g");
|
|
76
82
|
let m: RegExpExecArray | null;
|
|
77
83
|
|
|
78
84
|
while ((m = re.exec(src))) {
|
|
79
|
-
|
|
85
|
+
const start = m.index + m[0].length;
|
|
86
|
+
let i = start;
|
|
80
87
|
let depth = 1;
|
|
81
88
|
let quote: string | null = null;
|
|
82
89
|
let prev = "";
|
|
90
|
+
/** 调用参数里所有顶层 `[...]` 的起止下标 */
|
|
91
|
+
const topArrays: Array<[number, number]> = [];
|
|
92
|
+
let openAt = -1;
|
|
83
93
|
|
|
84
94
|
while (i < src.length && depth > 0) {
|
|
85
95
|
const c = src[i];
|
|
86
96
|
if (quote) {
|
|
87
97
|
if (c === quote && prev !== "\\") quote = null;
|
|
88
98
|
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
89
|
-
else if (c === "(" || c === "[" || c === "{")
|
|
90
|
-
|
|
99
|
+
else if (c === "(" || c === "[" || c === "{") {
|
|
100
|
+
if (depth === 1 && c === "[") openAt = i;
|
|
101
|
+
depth++;
|
|
102
|
+
} else if (c === ")" || c === "]" || c === "}") {
|
|
103
|
+
depth--;
|
|
104
|
+
if (depth === 1 && c === "]" && openAt >= 0) {
|
|
105
|
+
topArrays.push([openAt, i]);
|
|
106
|
+
openAt = -1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
91
109
|
prev = c;
|
|
92
110
|
i++;
|
|
93
111
|
}
|
|
94
112
|
|
|
95
|
-
const
|
|
96
|
-
const deps =
|
|
113
|
+
const closeParen = i - 1;
|
|
114
|
+
const deps = topArrays[topArrays.length - 1];
|
|
115
|
+
// 依赖数组必须是最后一个实参:前面紧邻一个顶层逗号(把 `useMemo(() => [x])`
|
|
116
|
+
// 这种「返回数组、根本没依赖数组」的写法排除掉),后面到 `)` 只剩逗号与空白
|
|
97
117
|
if (!deps) continue;
|
|
118
|
+
if (!/,\s*$/.test(src.slice(start, deps[0]))) continue;
|
|
119
|
+
if (!/^\s*,?\s*$/.test(src.slice(deps[1] + 1, closeParen))) continue;
|
|
98
120
|
|
|
99
121
|
const line = src.slice(0, m.index).split("\n").length;
|
|
100
|
-
for (const raw of deps[1]
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
.trim();
|
|
106
|
-
if (name) found.push([line, name]);
|
|
122
|
+
for (const raw of splitTopLevel(src.slice(deps[0] + 1, deps[1]))) {
|
|
123
|
+
const expr = raw.replace(/\/\/.*$/gm, "").trim();
|
|
124
|
+
if (!expr) continue;
|
|
125
|
+
const name = expr.split(/[.?[(!]/)[0].trim();
|
|
126
|
+
if (name) found.push([line, name, expr]);
|
|
107
127
|
}
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
return found;
|
|
111
131
|
}
|
|
112
132
|
|
|
133
|
+
/** 按顶层逗号切分(括号/引号内部的逗号不算) */
|
|
134
|
+
function splitTopLevel(text: string): string[] {
|
|
135
|
+
const parts: string[] = [];
|
|
136
|
+
let depth = 0;
|
|
137
|
+
let quote: string | null = null;
|
|
138
|
+
let prev = "";
|
|
139
|
+
let cur = "";
|
|
140
|
+
|
|
141
|
+
for (const c of text) {
|
|
142
|
+
if (quote) {
|
|
143
|
+
if (c === quote && prev !== "\\") quote = null;
|
|
144
|
+
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
145
|
+
else if (c === "(" || c === "[" || c === "{") depth++;
|
|
146
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
147
|
+
else if (c === "," && depth === 0) {
|
|
148
|
+
parts.push(cur);
|
|
149
|
+
cur = "";
|
|
150
|
+
prev = c;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
cur += c;
|
|
154
|
+
prev = c;
|
|
155
|
+
}
|
|
156
|
+
parts.push(cur);
|
|
157
|
+
return parts;
|
|
158
|
+
}
|
|
159
|
+
|
|
113
160
|
/**
|
|
114
161
|
* 找出「每次渲染都会新建一个对象/数组」的解构绑定,返回 `绑定名 → 说明`。
|
|
115
162
|
*
|
|
@@ -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
|
+
});
|
|
@@ -31,36 +31,35 @@ import {
|
|
|
31
31
|
const SRC = path.resolve(__dirname, "..");
|
|
32
32
|
const HOOK_RE = /use(?:Memo|Callback|(?:Layout)?Effect)\(/g;
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
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
|
+
};
|
|
36
47
|
|
|
37
48
|
/**
|
|
38
|
-
* 存量欠账:守卫上线(2.5.6
|
|
39
|
-
* 一轮的改动范围内(这轮只修 Chart 一族)。修法与 Chart 相同:rest 包走
|
|
40
|
-
* `useShallowStable`,字面量默认值提到模块级常量。
|
|
49
|
+
* 存量欠账:守卫上线(2.5.6)时就已经存在的命中。2.5.7 已逐处清完,名单为空。
|
|
41
50
|
*
|
|
42
51
|
* 这份名单**只许变短**——下面「只许变短」那条用例会在某项修好却忘了删时报错。
|
|
43
52
|
* 新写的代码一律直接失败,不许往这里加。
|
|
44
53
|
*/
|
|
45
|
-
const KNOWN_DEBT = [
|
|
46
|
-
"components/Checkbox/CheckboxGroup/index.tsx::options",
|
|
47
|
-
"components/FormItem/FormCodeMirror/index.tsx::rules",
|
|
48
|
-
"components/FormItem/ItemContainer/index.tsx::tipsConfig",
|
|
49
|
-
"components/Operate/index.tsx::menu",
|
|
50
|
-
"components/Pagination/index.tsx::pageSizeOptions",
|
|
51
|
-
"components/Panel/ListPanel/ListModalPanel/index.tsx::modalConfig",
|
|
52
|
-
"components/Search/_hooks/useSearchCommon.ts::moreSearchItems",
|
|
53
|
-
"components/Select/AutoCompleteSelect/index.tsx::options",
|
|
54
|
-
"components/Spreadsheet/index.tsx::xOptionsRest",
|
|
55
|
-
"components/Table/_hooks/useTableColumns.tsx::columns",
|
|
56
|
-
"components/Tree/index.tsx::treeData",
|
|
57
|
-
];
|
|
54
|
+
const KNOWN_DEBT: string[] = [];
|
|
58
55
|
|
|
59
56
|
interface Hit {
|
|
60
57
|
/** `文件相对路径::依赖名` */
|
|
61
58
|
key: string;
|
|
62
59
|
file: string;
|
|
63
60
|
line: number;
|
|
61
|
+
/** 依赖数组里的原文(`x` 还是 `x.open`),白名单按它精确匹配 */
|
|
62
|
+
raw: string;
|
|
64
63
|
why: string;
|
|
65
64
|
}
|
|
66
65
|
|
|
@@ -73,10 +72,10 @@ function scan(): Hit[] {
|
|
|
73
72
|
const fresh = freshObjectBindings(src);
|
|
74
73
|
if (!fresh.size) continue;
|
|
75
74
|
|
|
76
|
-
for (const [line, dep] of hookDeps(src, HOOK_RE)) {
|
|
75
|
+
for (const [line, dep, raw] of hookDeps(src, HOOK_RE)) {
|
|
77
76
|
const why = fresh.get(dep);
|
|
78
77
|
if (!why) continue;
|
|
79
|
-
hits.push({ key: `${rel}::${dep}`, file: rel, line, why });
|
|
78
|
+
hits.push({ key: `${rel}::${dep}`, file: rel, line, raw, why });
|
|
80
79
|
}
|
|
81
80
|
}
|
|
82
81
|
|
|
@@ -86,13 +85,22 @@ function scan(): Hit[] {
|
|
|
86
85
|
describe("每次渲染都新建的对象不进 hook 依赖数组", () => {
|
|
87
86
|
it("全库无未登记的新命中", () => {
|
|
88
87
|
const offenders = scan()
|
|
89
|
-
.filter(({ key }) =>
|
|
88
|
+
.filter(({ key, raw }) => ALLOWED[key]?.raw !== raw && !KNOWN_DEBT.includes(key))
|
|
90
89
|
.map(({ file, line, why }) => `${file}:${line} ${why}`);
|
|
91
90
|
|
|
92
91
|
expect(offenders).toEqual([]);
|
|
93
92
|
});
|
|
94
93
|
|
|
95
|
-
it("
|
|
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 修的就是它)", () => {
|
|
96
104
|
const chartHits = scan()
|
|
97
105
|
.filter(({ file }) => file.startsWith("components/Chart/"))
|
|
98
106
|
.map(({ key, line }) => `${key}@${line}`);
|
|
@@ -100,7 +108,7 @@ describe("每次渲染都新建的对象不进 hook 依赖数组", () => {
|
|
|
100
108
|
expect(chartHits).toEqual([]);
|
|
101
109
|
});
|
|
102
110
|
|
|
103
|
-
it("
|
|
111
|
+
it("存量欠账名单只许变短(2.5.7 起应恒为空)", () => {
|
|
104
112
|
const live = new Set(scan().map(({ key }) => key));
|
|
105
113
|
// 修好了就把它从 KNOWN_DEBT 里删掉,别留着已经不存在的条目
|
|
106
114
|
expect(KNOWN_DEBT.filter((key) => !live.has(key))).toEqual([]);
|
|
@@ -134,6 +142,29 @@ describe("每次渲染都新建的对象不进 hook 依赖数组", () => {
|
|
|
134
142
|
`;
|
|
135
143
|
expect(freshObjectBindings(fixed).has("coreOption")).toBe(false);
|
|
136
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
|
+
|
|
137
168
|
// 注释里的示例代码不算命中
|
|
138
169
|
expect(
|
|
139
170
|
freshObjectBindings(
|
|
@@ -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;
|