@hsu-react/ui 2.5.5 → 2.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/es/components/Chart/Bar/index.js +6 -1
  2. package/es/components/Chart/Bubble/index.js +6 -1
  3. package/es/components/Chart/Common/index.js +6 -1
  4. package/es/components/Chart/Gauge/index.js +6 -1
  5. package/es/components/Chart/Heatmap/index.js +9 -2
  6. package/es/components/Chart/Line/index.js +6 -1
  7. package/es/components/Chart/Pie/Pie3D/index.js +15 -7
  8. package/es/components/Chart/Pie/index.js +9 -2
  9. package/es/components/Chart/Polar/index.js +9 -2
  10. package/es/components/Chart/Radar/index.js +6 -1
  11. package/es/components/Checkbox/CheckboxGroup/index.js +4 -1
  12. package/es/components/FormItem/FormCodeMirror/index.js +3 -1
  13. package/es/components/FormItem/ItemContainer/index.js +15 -5
  14. package/es/components/Operate/index.js +4 -1
  15. package/es/components/Pagination/index.js +3 -1
  16. package/es/components/Search/_hooks/useSearchCommon.js +4 -1
  17. package/es/components/Select/AutoCompleteSelect/index.js +3 -1
  18. package/es/components/Spreadsheet/index.js +8 -2
  19. package/es/components/Table/_hooks/useTableColumns.js +4 -1
  20. package/es/components/Tree/_hooks/useTreeState.js +7 -4
  21. package/es/components/Tree/index.js +8 -2
  22. package/es/hooks/useShallowStable.d.ts +32 -0
  23. package/es/hooks/useShallowStable.js +54 -0
  24. package/lib/components/Chart/Bar/index.js +6 -1
  25. package/lib/components/Chart/Bubble/index.js +6 -1
  26. package/lib/components/Chart/Common/index.js +6 -1
  27. package/lib/components/Chart/Gauge/index.js +6 -1
  28. package/lib/components/Chart/Heatmap/index.js +9 -2
  29. package/lib/components/Chart/Line/index.js +6 -1
  30. package/lib/components/Chart/Pie/Pie3D/index.js +15 -7
  31. package/lib/components/Chart/Pie/index.js +9 -2
  32. package/lib/components/Chart/Polar/index.js +9 -2
  33. package/lib/components/Chart/Radar/index.js +6 -1
  34. package/lib/components/Checkbox/CheckboxGroup/index.js +4 -1
  35. package/lib/components/FormItem/FormCodeMirror/index.js +3 -1
  36. package/lib/components/FormItem/ItemContainer/index.js +16 -6
  37. package/lib/components/Operate/index.js +4 -1
  38. package/lib/components/Pagination/index.js +3 -1
  39. package/lib/components/Search/_hooks/useSearchCommon.js +4 -1
  40. package/lib/components/Select/AutoCompleteSelect/index.js +3 -1
  41. package/lib/components/Spreadsheet/index.js +8 -2
  42. package/lib/components/Table/_hooks/useTableColumns.js +4 -1
  43. package/lib/components/Tree/_hooks/useTreeState.js +6 -3
  44. package/lib/components/Tree/index.js +8 -2
  45. package/lib/hooks/useShallowStable.d.ts +32 -0
  46. package/lib/hooks/useShallowStable.js +59 -0
  47. package/package.json +1 -1
  48. package/src/__tests__/callbackDepsGuard.test.ts +4 -51
  49. package/src/__tests__/depsScan.ts +205 -0
  50. package/src/__tests__/freshObjectDeps.test.tsx +380 -0
  51. package/src/__tests__/freshObjectDepsGuard.test.ts +175 -0
  52. package/src/components/Chart/Bar/index.tsx +6 -1
  53. package/src/components/Chart/Bubble/index.tsx +6 -1
  54. package/src/components/Chart/Common/index.tsx +6 -1
  55. package/src/components/Chart/Gauge/index.tsx +6 -1
  56. package/src/components/Chart/Heatmap/index.tsx +20 -12
  57. package/src/components/Chart/Line/index.tsx +6 -1
  58. package/src/components/Chart/Pie/Pie3D/index.tsx +12 -3
  59. package/src/components/Chart/Pie/index.tsx +10 -2
  60. package/src/components/Chart/Polar/index.tsx +10 -2
  61. package/src/components/Chart/Radar/index.tsx +6 -1
  62. package/src/components/Chart/optionMemo.test.tsx +232 -0
  63. package/src/components/Checkbox/CheckboxGroup/index.tsx +4 -1
  64. package/src/components/FormItem/FormCodeMirror/index.tsx +4 -1
  65. package/src/components/FormItem/ItemContainer/index.tsx +21 -6
  66. package/src/components/Operate/index.tsx +4 -1
  67. package/src/components/Pagination/index.tsx +4 -1
  68. package/src/components/Search/_hooks/useSearchCommon.ts +4 -1
  69. package/src/components/Select/AutoCompleteSelect/index.tsx +4 -1
  70. package/src/components/Spreadsheet/depsMemo.test.tsx +95 -0
  71. package/src/components/Spreadsheet/index.tsx +9 -2
  72. package/src/components/Table/_hooks/useTableColumns.tsx +4 -1
  73. package/src/components/Tree/_hooks/useTreeState.ts +14 -8
  74. package/src/components/Tree/depsMemo.test.tsx +64 -0
  75. package/src/components/Tree/index.tsx +9 -2
  76. package/src/hooks/useShallowStable.ts +65 -0
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ exports.useShallowStable = useShallowStable;
8
+ var _react = require("react");
9
+ /**
10
+ * 把一个**内容没变、但每次渲染都新建**的对象收敛成**引用恒定**的对象:
11
+ * 浅比较(自有键 + `Object.is` 比值)相等就继续返回上一次那个引用。
12
+ *
13
+ * 用途只有一个:让它可以直接进 `useMemo` / `useEffect` 的依赖数组。
14
+ *
15
+ * React 的依赖数组按 `Object.is` 比引用,所以依赖里只要混进一个「每次渲染都新建
16
+ * 的对象」,memo 就**恒不命中**、effect 就**每渲染必重跑**。组件内部最容易出现
17
+ * 这种对象的地方是 rest 解构:
18
+ *
19
+ * ```tsx
20
+ * const { className, style, ...coreOption } = props; // coreOption 每次都是新对象
21
+ * const option = useMemo(() => ({ ...coreOption }), [coreOption]); // 永远不命中
22
+ * ```
23
+ *
24
+ * 消费方就算把每一个 prop 都 memo 好,也躲不掉——新对象是组件自己建的。
25
+ * 正确写法是让这个对象回到值语义:
26
+ *
27
+ * ```tsx
28
+ * const { className, style, ...restOption } = props;
29
+ * const coreOption = useShallowStable(restOption); // 内容不变 → 引用不变
30
+ * ```
31
+ *
32
+ * 注意这**不是**「缓存结果跳过副作用」:memo 该重算时照样重算、effect 该重跑时
33
+ * 照样重跑——只要 rest 里任何一个值真的变了,浅比较就不相等,新引用立刻透出去。
34
+ *
35
+ * 渲染期间读写 ref 的安全性:并发渲染下被丢弃的那次渲染可能已经把 ref 换成了它
36
+ * 自己的对象,但那个对象与当次 props 浅相等,所以后续渲染拿到的引用**内容永远
37
+ * 与当前 props 一致**,不会读到过期值。
38
+ */
39
+ function useShallowStable(value) {
40
+ const ref = (0, _react.useRef)(value);
41
+ if (!shallowEqual(ref.current, value)) {
42
+ ref.current = value;
43
+ }
44
+ return ref.current;
45
+ }
46
+ function shallowEqual(a, b) {
47
+ if (Object.is(a, b)) return true;
48
+ const aKeys = Object.keys(a);
49
+ const bKeys = Object.keys(b);
50
+ if (aKeys.length !== bKeys.length) return false;
51
+ for (const key of aKeys) {
52
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
53
+ if (!Object.is(a[key], b[key])) {
54
+ return false;
55
+ }
56
+ }
57
+ return true;
58
+ }
59
+ var _default = exports.default = useShallowStable;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hsu-react/ui",
3
- "version": "2.5.5",
3
+ "version": "2.5.7",
4
4
  "description": "一套基于 React + Ant Design 的中后台业务组件库",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
+ import { hookDeps, walkSources } from "./depsScan";
4
5
 
5
6
  /**
6
7
  * 防回归守卫:**回调 prop 不许进 effect 依赖数组**。
@@ -37,62 +38,14 @@ const ALLOWED: Record<string, string> = {
37
38
  "components/Tree/index.tsx::onSelectPath": "同上,纯 ref 同步 effect",
38
39
  };
39
40
 
40
- function walk(dir: string, out: string[] = []) {
41
- for (const name of fs.readdirSync(dir)) {
42
- const full = path.join(dir, name);
43
- if (fs.statSync(full).isDirectory()) walk(full, out);
44
- else if (/\.(tsx?|jsx?)$/.test(name) && !/\.test\./.test(name))
45
- out.push(full);
46
- }
47
- return out;
48
- }
49
-
50
- /** 找出每个 useEffect / useLayoutEffect 的依赖数组,返回 [行号, 依赖名] */
51
- function effectDeps(src: string): Array<[number, string]> {
52
- const found: Array<[number, string]> = [];
53
- const re = /use(?:Layout)?Effect\(/g;
54
- let m: RegExpExecArray | null;
55
-
56
- while ((m = re.exec(src))) {
57
- let i = m.index + m[0].length;
58
- let depth = 1;
59
- let quote: string | null = null;
60
- let prev = "";
61
-
62
- while (i < src.length && depth > 0) {
63
- const c = src[i];
64
- if (quote) {
65
- if (c === quote && prev !== "\\") quote = null;
66
- } else if (c === '"' || c === "'" || c === "`") quote = c;
67
- else if (c === "(" || c === "[" || c === "{") depth++;
68
- else if (c === ")" || c === "]" || c === "}") depth--;
69
- prev = c;
70
- i++;
71
- }
72
-
73
- const body = src.slice(m.index, i).replace(/\)\s*$/, "");
74
- const deps = body.match(/,\s*\[([\s\S]*)\]\s*$/);
75
- if (!deps) continue;
76
-
77
- const line = src.slice(0, m.index).split("\n").length;
78
- for (const raw of deps[1].split(",")) {
79
- const name = raw
80
- .replace(/\/\/.*$/gm, "")
81
- .trim()
82
- .split(/[.?[]/)[0]
83
- .trim();
84
- if (name) found.push([line, name]);
85
- }
86
- }
87
-
88
- return found;
89
- }
41
+ const EFFECT_RE = /use(?:Layout)?Effect\(/g;
42
+ const effectDeps = (src: string) => hookDeps(src, EFFECT_RE);
90
43
 
91
44
  describe("回调 prop 不进 effect 依赖数组", () => {
92
45
  it("全库无未登记的命中", () => {
93
46
  const offenders: string[] = [];
94
47
 
95
- for (const file of walk(SRC)) {
48
+ for (const file of walkSources(SRC)) {
96
49
  const rel = path.relative(SRC, file).split(path.sep).join("/");
97
50
  const src = fs.readFileSync(file, "utf8");
98
51
 
@@ -0,0 +1,205 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * 两条防回归守卫(`callbackDepsGuard` / `freshObjectDepsGuard`)共用的源码扫描工具。
6
+ * 只做静态文本扫描,故意不引入 AST 依赖——守卫本身要足够轻,才不会被人嫌慢删掉。
7
+ */
8
+
9
+ /** 去掉注释再扫描:文档注释里的示例代码不是真实代码,不能算命中 */
10
+ export function stripComments(src: string) {
11
+ let out = "";
12
+ let i = 0;
13
+ let quote: string | null = null;
14
+ while (i < src.length) {
15
+ const c = src[i];
16
+ const next = src[i + 1];
17
+ if (quote) {
18
+ out += c;
19
+ if (c === "\\") {
20
+ out += next ?? "";
21
+ i += 2;
22
+ continue;
23
+ }
24
+ if (c === quote) quote = null;
25
+ i++;
26
+ continue;
27
+ }
28
+ if (c === '"' || c === "'" || c === "`") {
29
+ quote = c;
30
+ out += c;
31
+ i++;
32
+ continue;
33
+ }
34
+ if (c === "/" && next === "/") {
35
+ while (i < src.length && src[i] !== "\n") i++;
36
+ continue;
37
+ }
38
+ if (c === "/" && next === "*") {
39
+ i += 2;
40
+ // 把块注释里的换行原样留下,报出来的行号才对得上
41
+ while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
42
+ if (src[i] === "\n") out += "\n";
43
+ i++;
44
+ }
45
+ i += 2;
46
+ continue;
47
+ }
48
+ out += c;
49
+ i++;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** 遍历 `src/` 下的源码文件;守卫自己的目录不参与扫描 */
55
+ export function walkSources(dir: string, out: string[] = []) {
56
+ for (const name of fs.readdirSync(dir)) {
57
+ if (name === "__tests__") continue;
58
+ const full = path.join(dir, name);
59
+ if (fs.statSync(full).isDirectory()) walkSources(full, out);
60
+ else if (/\.(tsx?|jsx?)$/.test(name) && !/\.test\./.test(name))
61
+ out.push(full);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * 找出每个匹配 `hookRe` 的 hook 调用的依赖数组,返回 `[行号, 依赖名, 依赖原文]`。
68
+ * `hookRe` 必须带 `g`,且以 `\(` 结尾(例如 `/use(?:Layout)?Effect\(/g`)。
69
+ *
70
+ * 依赖数组按「调用参数里最后一个顶层 `[...]`,且它后面到 `)` 之间只剩逗号/空白」
71
+ * 来定位。早先用 `/,\s*\[([\s\S]*)\]\s*$/` 正则从**最左**的 `, [` 起贪婪匹配,
72
+ * 回调体里只要出现一次 `, [`(JSX 的 `classNames(x, { [styles.a]: b })` 就会),
73
+ * 真正的依赖数组就被并进同一段,切出来的是带换行的 JSX 碎片而不是标识符 ——
74
+ * 结果是真命中被静默漏掉。这里改成括号配平扫描,不再漏。
75
+ */
76
+ export function hookDeps(
77
+ src: string,
78
+ hookRe: RegExp,
79
+ ): Array<[number, string, string]> {
80
+ const found: Array<[number, string, string]> = [];
81
+ const re = new RegExp(hookRe.source, "g");
82
+ let m: RegExpExecArray | null;
83
+
84
+ while ((m = re.exec(src))) {
85
+ const start = m.index + m[0].length;
86
+ let i = start;
87
+ let depth = 1;
88
+ let quote: string | null = null;
89
+ let prev = "";
90
+ /** 调用参数里所有顶层 `[...]` 的起止下标 */
91
+ const topArrays: Array<[number, number]> = [];
92
+ let openAt = -1;
93
+
94
+ while (i < src.length && depth > 0) {
95
+ const c = src[i];
96
+ if (quote) {
97
+ if (c === quote && prev !== "\\") quote = null;
98
+ } else if (c === '"' || c === "'" || c === "`") quote = c;
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
+ }
109
+ prev = c;
110
+ i++;
111
+ }
112
+
113
+ const closeParen = i - 1;
114
+ const deps = topArrays[topArrays.length - 1];
115
+ // 依赖数组必须是最后一个实参:前面紧邻一个顶层逗号(把 `useMemo(() => [x])`
116
+ // 这种「返回数组、根本没依赖数组」的写法排除掉),后面到 `)` 只剩逗号与空白
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;
120
+
121
+ const line = src.slice(0, m.index).split("\n").length;
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]);
127
+ }
128
+ }
129
+
130
+ return found;
131
+ }
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
+
160
+ /**
161
+ * 找出「每次渲染都会新建一个对象/数组」的解构绑定,返回 `绑定名 → 说明`。
162
+ *
163
+ * 两种形态:
164
+ * - rest 包:`const { a, ...rest } = props` —— `rest` 每次都是新对象;
165
+ * - 字面量默认值:`const { list = [] } = props` —— 消费方没传时每次都是新数组。
166
+ */
167
+ export function freshObjectBindings(src: string): Map<string, string> {
168
+ const found = new Map<string, string>();
169
+
170
+ const re = /\b(?:const|let|var)\s*\{/g;
171
+ let m: RegExpExecArray | null;
172
+
173
+ while ((m = re.exec(src))) {
174
+ // 取出与 `{` 配对的那一段解构模式
175
+ let i = re.lastIndex;
176
+ let depth = 1;
177
+ let quote: string | null = null;
178
+ let prev = "";
179
+ while (i < src.length && depth > 0) {
180
+ const c = src[i];
181
+ if (quote) {
182
+ if (c === quote && prev !== "\\") quote = null;
183
+ } else if (c === '"' || c === "'" || c === "`") quote = c;
184
+ else if (c === "(" || c === "[" || c === "{") depth++;
185
+ else if (c === ")" || c === "]" || c === "}") depth--;
186
+ prev = c;
187
+ i++;
188
+ }
189
+ // 后面必须紧跟 `=`,否则不是解构赋值
190
+ if (!/^\s*=/.test(src.slice(i))) continue;
191
+
192
+ const pattern = src.slice(re.lastIndex, i - 1);
193
+
194
+ for (const r of pattern.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
195
+ found.set(r[1], "rest 解构包:每次渲染都是新对象");
196
+ }
197
+ for (const r of pattern.matchAll(
198
+ /([A-Za-z_$][\w$]*)\s*=\s*(?:[[{])/g,
199
+ )) {
200
+ found.set(r[1], "解构默认值是对象/数组字面量:不传时每次渲染都是新对象");
201
+ }
202
+ }
203
+
204
+ return found;
205
+ }