@hsu-react/ui 2.5.6 → 2.5.8

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 (61) hide show
  1. package/es/components/Checkbox/CheckboxGroup/index.js +4 -1
  2. package/es/components/FormItem/FormCodeMirror/index.js +3 -1
  3. package/es/components/FormItem/ItemContainer/index.js +15 -5
  4. package/es/components/Modal/index.d.ts +12 -0
  5. package/es/components/Modal/index.js +22 -1
  6. package/es/components/Modal/index.module.scss +7 -0
  7. package/es/components/Operate/index.js +4 -1
  8. package/es/components/Pagination/index.js +3 -1
  9. package/es/components/Search/_hooks/useSearchCommon.js +4 -1
  10. package/es/components/Select/AutoCompleteSelect/index.js +3 -1
  11. package/es/components/Spreadsheet/index.js +8 -2
  12. package/es/components/Table/_hooks/useTableColumns.js +4 -1
  13. package/es/components/Tree/_hooks/useTreeState.js +7 -4
  14. package/es/components/Tree/index.js +8 -2
  15. package/es/layout/Menu/index.module.scss +32 -0
  16. package/es/styles/tokens.d.ts +10 -0
  17. package/es/styles/tokens.js +11 -0
  18. package/es/styles/tokens.json +1 -0
  19. package/lib/components/Checkbox/CheckboxGroup/index.js +4 -1
  20. package/lib/components/FormItem/FormCodeMirror/index.js +3 -1
  21. package/lib/components/FormItem/ItemContainer/index.js +16 -6
  22. package/lib/components/Modal/index.d.ts +12 -0
  23. package/lib/components/Modal/index.js +22 -0
  24. package/lib/components/Modal/index.module.scss +7 -0
  25. package/lib/components/Operate/index.js +4 -1
  26. package/lib/components/Pagination/index.js +3 -1
  27. package/lib/components/Search/_hooks/useSearchCommon.js +4 -1
  28. package/lib/components/Select/AutoCompleteSelect/index.js +3 -1
  29. package/lib/components/Spreadsheet/index.js +8 -2
  30. package/lib/components/Table/_hooks/useTableColumns.js +4 -1
  31. package/lib/components/Tree/_hooks/useTreeState.js +6 -3
  32. package/lib/components/Tree/index.js +8 -2
  33. package/lib/layout/Menu/index.module.scss +32 -0
  34. package/lib/styles/tokens.d.ts +10 -0
  35. package/lib/styles/tokens.js +12 -1
  36. package/lib/styles/tokens.json +1 -0
  37. package/package.json +1 -1
  38. package/src/__tests__/depsScan.ts +62 -15
  39. package/src/__tests__/freshObjectDeps.test.tsx +380 -0
  40. package/src/__tests__/freshObjectDepsGuard.test.ts +54 -23
  41. package/src/components/Checkbox/CheckboxGroup/index.tsx +4 -1
  42. package/src/components/FormItem/FormCodeMirror/index.tsx +4 -1
  43. package/src/components/FormItem/ItemContainer/index.tsx +21 -6
  44. package/src/components/Modal/index.md +1 -0
  45. package/src/components/Modal/index.module.scss +7 -0
  46. package/src/components/Modal/index.tsx +40 -0
  47. package/src/components/Modal/minHeight.test.tsx +105 -0
  48. package/src/components/Operate/index.tsx +4 -1
  49. package/src/components/Pagination/index.tsx +4 -1
  50. package/src/components/Search/_hooks/useSearchCommon.ts +4 -1
  51. package/src/components/Select/AutoCompleteSelect/index.tsx +4 -1
  52. package/src/components/Spreadsheet/depsMemo.test.tsx +95 -0
  53. package/src/components/Spreadsheet/index.tsx +9 -2
  54. package/src/components/Table/_hooks/useTableColumns.tsx +4 -1
  55. package/src/components/Tree/_hooks/useTreeState.ts +14 -8
  56. package/src/components/Tree/depsMemo.test.tsx +64 -0
  57. package/src/components/Tree/index.tsx +9 -2
  58. package/src/layout/Menu/index.module.scss +32 -0
  59. package/src/layout/Menu/scrollGutter.test.ts +50 -0
  60. package/src/styles/tokens.json +1 -0
  61. package/src/styles/tokens.ts +11 -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
+ });
@@ -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
- const ALLOWED: Record<string, string> = {};
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 }) => !ALLOWED[key] && !KNOWN_DEBT.includes(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("Chart 一族一条都不剩(这一轮修的就是它)", () => {
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;
@@ -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"
@@ -74,6 +74,7 @@ Modal.config
74
74
  | edgeDetection | 拖拽时是否进行边缘检测,防止拖出视口 | `boolean` | `true` |
75
75
  | full | 是否以全屏方式展示 | `boolean` | `false` |
76
76
  | titleButtonGroup | 标题区右侧的按钮组配置 | `ButtonProps[]` | - |
77
+ | minHeight | 高度**下界**,给内容要等接口回来才画得出的弹窗用(详情 / 消息 / 记录)。`true` 走标准档 700px;数字按 px;字符串原样当 CSS 长度。只是下界,内容更高照常撑开与滚动,并且与 `90vh` 取小,矮窗口不会被顶出屏幕。**短表单不要传**,否则平白留一片空白 | `number \| string \| boolean` | - |
77
78
 
78
79
  > 其余属性(`open`、`onOk`、`onCancel`、`footer`、`width` 等)与 antd `Modal` 一致。
79
80
  >
@@ -31,6 +31,13 @@
31
31
  width: 100%;
32
32
  height: 100%;
33
33
  max-height: 90vh;
34
+ // 高度下界,由 `<Modal minHeight>` 写入 `--vita-modal-min-height`(不传就是 0)。
35
+ // 内容异步加载的弹窗不给下界的话,打开那一刻只有标题栏那么高,数据回来再撑开。
36
+ //
37
+ // 必须在这里与 90vh 取小:CSS 里 `min-height` 的优先级高于 `max-height`,
38
+ // 直接写 `min-height: 700px` 会让上面那行 `max-height: 90vh` 在矮窗口上失效,
39
+ // 弹窗被顶出屏幕。
40
+ min-height: min(var(--vita-modal-min-height, 0px), 90vh);
34
41
  padding: 0px 0px 15px;
35
42
  }
36
43