@hsu-react/ui 2.5.4 → 2.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/components/ChainGraph/_hooks/useChainGraphData.js +29 -8
- package/es/components/ChainGraph/_hooks/useChainGraphLayout.js +14 -3
- package/es/components/Chart/Bar/index.js +55 -20
- package/es/components/Chart/Bubble/index.js +21 -6
- package/es/components/Chart/Common/index.js +6 -1
- package/es/components/Chart/Gauge/index.js +6 -1
- package/es/components/Chart/Heatmap/index.js +24 -7
- package/es/components/Chart/Line/index.js +55 -20
- package/es/components/Chart/Pie/Pie3D/index.js +30 -12
- package/es/components/Chart/Pie/index.js +24 -7
- package/es/components/Chart/Polar/index.js +9 -2
- package/es/components/Chart/Radar/index.js +6 -1
- package/es/components/Chart/Sankey/index.js +11 -2
- package/es/components/Chart/Tree/index.js +11 -2
- package/es/components/Select/_hooks/useSelectComposition.js +6 -2
- package/es/components/Table/_hooks/useAutoScrolling.js +16 -7
- package/es/hooks/useShallowStable.d.ts +32 -0
- package/es/hooks/useShallowStable.js +54 -0
- package/es/layout/Menu/_hooks/useOnlyLvOneMenu.js +9 -2
- package/lib/components/ChainGraph/_hooks/useChainGraphData.js +24 -7
- package/lib/components/ChainGraph/_hooks/useChainGraphLayout.js +12 -2
- package/lib/components/Chart/Bar/index.js +51 -19
- package/lib/components/Chart/Bubble/index.js +20 -6
- package/lib/components/Chart/Common/index.js +6 -1
- package/lib/components/Chart/Gauge/index.js +6 -1
- package/lib/components/Chart/Heatmap/index.js +23 -7
- package/lib/components/Chart/Line/index.js +51 -19
- package/lib/components/Chart/Pie/Pie3D/index.js +29 -12
- package/lib/components/Chart/Pie/index.js +23 -7
- package/lib/components/Chart/Polar/index.js +9 -2
- package/lib/components/Chart/Radar/index.js +6 -1
- package/lib/components/Chart/Sankey/index.js +10 -2
- package/lib/components/Chart/Tree/index.js +10 -2
- package/lib/components/Select/_hooks/useSelectComposition.js +6 -2
- package/lib/components/Table/_hooks/useAutoScrolling.js +16 -7
- package/lib/hooks/useShallowStable.d.ts +32 -0
- package/lib/hooks/useShallowStable.js +59 -0
- package/lib/layout/Menu/_hooks/useOnlyLvOneMenu.js +8 -2
- package/package.json +1 -1
- package/src/__tests__/callbackDepsGuard.test.ts +72 -0
- package/src/__tests__/depsScan.ts +158 -0
- package/src/__tests__/freshObjectDepsGuard.test.ts +144 -0
- package/src/components/ChainGraph/_hooks/callbacks.test.tsx +155 -0
- package/src/components/ChainGraph/_hooks/useChainGraphData.ts +38 -10
- package/src/components/ChainGraph/_hooks/useChainGraphLayout.ts +16 -3
- package/src/components/Chart/Bar/index.tsx +65 -22
- package/src/components/Chart/Bubble/index.tsx +21 -6
- package/src/components/Chart/Common/index.tsx +6 -1
- package/src/components/Chart/Gauge/index.tsx +6 -1
- package/src/components/Chart/Heatmap/index.tsx +39 -17
- package/src/components/Chart/Line/index.tsx +65 -22
- package/src/components/Chart/Pie/Pie3D/index.tsx +27 -8
- package/src/components/Chart/Pie/index.tsx +25 -7
- package/src/components/Chart/Polar/index.tsx +10 -2
- package/src/components/Chart/Radar/index.tsx +6 -1
- package/src/components/Chart/Sankey/index.tsx +10 -4
- package/src/components/Chart/Tree/index.tsx +10 -4
- package/src/components/Chart/callbacks.test.tsx +315 -0
- package/src/components/Chart/optionMemo.test.tsx +232 -0
- package/src/components/Select/_hooks/useSelectComposition.test.ts +47 -0
- package/src/components/Select/_hooks/useSelectComposition.ts +6 -2
- package/src/components/Table/_hooks/useAutoScrolling.test.tsx +95 -0
- package/src/components/Table/_hooks/useAutoScrolling.ts +16 -7
- package/src/hooks/useShallowStable.ts +65 -0
- package/src/layout/Menu/_hooks/useOnlyLvOneMenu.test.tsx +56 -0
- package/src/layout/Menu/_hooks/useOnlyLvOneMenu.ts +8 -2
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
export function hookDeps(
|
|
71
|
+
src: string,
|
|
72
|
+
hookRe: RegExp,
|
|
73
|
+
): Array<[number, string]> {
|
|
74
|
+
const found: Array<[number, string]> = [];
|
|
75
|
+
const re = new RegExp(hookRe.source, "g");
|
|
76
|
+
let m: RegExpExecArray | null;
|
|
77
|
+
|
|
78
|
+
while ((m = re.exec(src))) {
|
|
79
|
+
let i = m.index + m[0].length;
|
|
80
|
+
let depth = 1;
|
|
81
|
+
let quote: string | null = null;
|
|
82
|
+
let prev = "";
|
|
83
|
+
|
|
84
|
+
while (i < src.length && depth > 0) {
|
|
85
|
+
const c = src[i];
|
|
86
|
+
if (quote) {
|
|
87
|
+
if (c === quote && prev !== "\\") quote = null;
|
|
88
|
+
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
89
|
+
else if (c === "(" || c === "[" || c === "{") depth++;
|
|
90
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
91
|
+
prev = c;
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body = src.slice(m.index, i).replace(/\)\s*$/, "");
|
|
96
|
+
const deps = body.match(/,\s*\[([\s\S]*)\]\s*$/);
|
|
97
|
+
if (!deps) continue;
|
|
98
|
+
|
|
99
|
+
const line = src.slice(0, m.index).split("\n").length;
|
|
100
|
+
for (const raw of deps[1].split(",")) {
|
|
101
|
+
const name = raw
|
|
102
|
+
.replace(/\/\/.*$/gm, "")
|
|
103
|
+
.trim()
|
|
104
|
+
.split(/[.?[]/)[0]
|
|
105
|
+
.trim();
|
|
106
|
+
if (name) found.push([line, name]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 找出「每次渲染都会新建一个对象/数组」的解构绑定,返回 `绑定名 → 说明`。
|
|
115
|
+
*
|
|
116
|
+
* 两种形态:
|
|
117
|
+
* - rest 包:`const { a, ...rest } = props` —— `rest` 每次都是新对象;
|
|
118
|
+
* - 字面量默认值:`const { list = [] } = props` —— 消费方没传时每次都是新数组。
|
|
119
|
+
*/
|
|
120
|
+
export function freshObjectBindings(src: string): Map<string, string> {
|
|
121
|
+
const found = new Map<string, string>();
|
|
122
|
+
|
|
123
|
+
const re = /\b(?:const|let|var)\s*\{/g;
|
|
124
|
+
let m: RegExpExecArray | null;
|
|
125
|
+
|
|
126
|
+
while ((m = re.exec(src))) {
|
|
127
|
+
// 取出与 `{` 配对的那一段解构模式
|
|
128
|
+
let i = re.lastIndex;
|
|
129
|
+
let depth = 1;
|
|
130
|
+
let quote: string | null = null;
|
|
131
|
+
let prev = "";
|
|
132
|
+
while (i < src.length && depth > 0) {
|
|
133
|
+
const c = src[i];
|
|
134
|
+
if (quote) {
|
|
135
|
+
if (c === quote && prev !== "\\") quote = null;
|
|
136
|
+
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
137
|
+
else if (c === "(" || c === "[" || c === "{") depth++;
|
|
138
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
139
|
+
prev = c;
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
// 后面必须紧跟 `=`,否则不是解构赋值
|
|
143
|
+
if (!/^\s*=/.test(src.slice(i))) continue;
|
|
144
|
+
|
|
145
|
+
const pattern = src.slice(re.lastIndex, i - 1);
|
|
146
|
+
|
|
147
|
+
for (const r of pattern.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
|
|
148
|
+
found.set(r[1], "rest 解构包:每次渲染都是新对象");
|
|
149
|
+
}
|
|
150
|
+
for (const r of pattern.matchAll(
|
|
151
|
+
/([A-Za-z_$][\w$]*)\s*=\s*(?:[[{])/g,
|
|
152
|
+
)) {
|
|
153
|
+
found.set(r[1], "解构默认值是对象/数组字面量:不传时每次渲染都是新对象");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return found;
|
|
158
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
const ALLOWED: Record<string, string> = {};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 存量欠账:守卫上线(2.5.6)时就已经存在的命中。**它们不是无害的**,只是不在这
|
|
39
|
+
* 一轮的改动范围内(这轮只修 Chart 一族)。修法与 Chart 相同:rest 包走
|
|
40
|
+
* `useShallowStable`,字面量默认值提到模块级常量。
|
|
41
|
+
*
|
|
42
|
+
* 这份名单**只许变短**——下面「只许变短」那条用例会在某项修好却忘了删时报错。
|
|
43
|
+
* 新写的代码一律直接失败,不许往这里加。
|
|
44
|
+
*/
|
|
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
|
+
];
|
|
58
|
+
|
|
59
|
+
interface Hit {
|
|
60
|
+
/** `文件相对路径::依赖名` */
|
|
61
|
+
key: string;
|
|
62
|
+
file: string;
|
|
63
|
+
line: number;
|
|
64
|
+
why: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scan(): Hit[] {
|
|
68
|
+
const hits: Hit[] = [];
|
|
69
|
+
|
|
70
|
+
for (const file of walkSources(SRC)) {
|
|
71
|
+
const rel = path.relative(SRC, file).split(path.sep).join("/");
|
|
72
|
+
const src = stripComments(fs.readFileSync(file, "utf8"));
|
|
73
|
+
const fresh = freshObjectBindings(src);
|
|
74
|
+
if (!fresh.size) continue;
|
|
75
|
+
|
|
76
|
+
for (const [line, dep] of hookDeps(src, HOOK_RE)) {
|
|
77
|
+
const why = fresh.get(dep);
|
|
78
|
+
if (!why) continue;
|
|
79
|
+
hits.push({ key: `${rel}::${dep}`, file: rel, line, why });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return hits;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("每次渲染都新建的对象不进 hook 依赖数组", () => {
|
|
87
|
+
it("全库无未登记的新命中", () => {
|
|
88
|
+
const offenders = scan()
|
|
89
|
+
.filter(({ key }) => !ALLOWED[key] && !KNOWN_DEBT.includes(key))
|
|
90
|
+
.map(({ file, line, why }) => `${file}:${line} ${why}`);
|
|
91
|
+
|
|
92
|
+
expect(offenders).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("Chart 一族一条都不剩(这一轮修的就是它)", () => {
|
|
96
|
+
const chartHits = scan()
|
|
97
|
+
.filter(({ file }) => file.startsWith("components/Chart/"))
|
|
98
|
+
.map(({ key, line }) => `${key}@${line}`);
|
|
99
|
+
|
|
100
|
+
expect(chartHits).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("存量欠账名单只许变短", () => {
|
|
104
|
+
const live = new Set(scan().map(({ key }) => key));
|
|
105
|
+
// 修好了就把它从 KNOWN_DEBT 里删掉,别留着已经不存在的条目
|
|
106
|
+
expect(KNOWN_DEBT.filter((key) => !live.has(key))).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("守卫本身能抓到问题(自测)", () => {
|
|
110
|
+
const restCase = `
|
|
111
|
+
const { className, ...coreOption } = props;
|
|
112
|
+
const option = useMemo(() => ({ ...coreOption }), [coreOption]);
|
|
113
|
+
`;
|
|
114
|
+
expect(freshObjectBindings(restCase).has("coreOption")).toBe(true);
|
|
115
|
+
expect(hookDeps(restCase, HOOK_RE).map(([, d]) => d)).toContain(
|
|
116
|
+
"coreOption",
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const defaultCase = `
|
|
120
|
+
const { colors = ["#fff"], size = { w: 1 }, name = "x" } = props;
|
|
121
|
+
useEffect(() => {}, [colors, size, name]);
|
|
122
|
+
`;
|
|
123
|
+
const fresh = freshObjectBindings(defaultCase);
|
|
124
|
+
expect(fresh.has("colors")).toBe(true);
|
|
125
|
+
expect(fresh.has("size")).toBe(true);
|
|
126
|
+
// 原始值默认值不是新对象,不该被误判
|
|
127
|
+
expect(fresh.has("name")).toBe(false);
|
|
128
|
+
|
|
129
|
+
// 稳定引用(useShallowStable 的返回值 / 模块级常量)不该被误判
|
|
130
|
+
const fixed = `
|
|
131
|
+
const { className, ...restOption } = props;
|
|
132
|
+
const coreOption = useShallowStable(restOption);
|
|
133
|
+
const option = useMemo(() => ({ ...coreOption }), [coreOption]);
|
|
134
|
+
`;
|
|
135
|
+
expect(freshObjectBindings(fixed).has("coreOption")).toBe(false);
|
|
136
|
+
|
|
137
|
+
// 注释里的示例代码不算命中
|
|
138
|
+
expect(
|
|
139
|
+
freshObjectBindings(
|
|
140
|
+
stripComments(`/** const { ...coreOption } = props; */\nconst a = 1;`),
|
|
141
|
+
).size,
|
|
142
|
+
).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
vi.mock("../ChainGraphServices", () => ({ default: class {} }));
|
|
5
|
+
|
|
6
|
+
import { useChainGraphLayout } from "./useChainGraphLayout";
|
|
7
|
+
import { useChainGraphData } from "./useChainGraphData";
|
|
8
|
+
import type ChainGraphServices from "../ChainGraphServices";
|
|
9
|
+
import type { TreeGraphData } from "..";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ChainGraph 把回调交给图实例,由图实例在后续事件里回调。
|
|
13
|
+
* 这类「出口型」回调进依赖数组,会让消费方传内联箭头时反复触发重排 / 重新 setData。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DATA = { id: "root", label: "root" } as unknown as TreeGraphData;
|
|
17
|
+
|
|
18
|
+
describe("useChainGraphLayout", () => {
|
|
19
|
+
const makeGraph = () =>
|
|
20
|
+
({ changeLayout: vi.fn() }) as unknown as ChainGraphServices & {
|
|
21
|
+
changeLayout: ReturnType<typeof vi.fn>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
it("getImage 换引用不会重新 changeLayout(整张图重排)", () => {
|
|
25
|
+
const graph = makeGraph();
|
|
26
|
+
const { rerender } = renderHook(
|
|
27
|
+
({ getImage }: { getImage?: (img: string) => void }) =>
|
|
28
|
+
useChainGraphLayout({ graph, octopus: true, rootLevel: 0, getImage }),
|
|
29
|
+
{ initialProps: { getImage: vi.fn() } },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(graph.changeLayout).toHaveBeenCalledTimes(1);
|
|
33
|
+
|
|
34
|
+
rerender({ getImage: vi.fn() });
|
|
35
|
+
rerender({ getImage: vi.fn() });
|
|
36
|
+
|
|
37
|
+
expect(graph.changeLayout).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("出图时调到的是最新的 getImage,包括从无到有传入的情况", () => {
|
|
41
|
+
const graph = makeGraph();
|
|
42
|
+
const { rerender } = renderHook(
|
|
43
|
+
({ getImage }: { getImage?: (img: string) => void }) =>
|
|
44
|
+
useChainGraphLayout({ graph, octopus: true, rootLevel: 0, getImage }),
|
|
45
|
+
{ initialProps: {} as { getImage?: (img: string) => void } },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const forwarded = graph.changeLayout.mock.calls[0][2] as (
|
|
49
|
+
img: string,
|
|
50
|
+
) => void;
|
|
51
|
+
// 没传回调时转发函数照样存在,调用不报错
|
|
52
|
+
expect(() => forwarded("img-0")).not.toThrow();
|
|
53
|
+
|
|
54
|
+
const later = vi.fn();
|
|
55
|
+
rerender({ getImage: later });
|
|
56
|
+
// 图实例里存的还是同一个转发函数,但它现在会转给新传入的回调
|
|
57
|
+
forwarded("img-1");
|
|
58
|
+
expect(later).toHaveBeenCalledWith("img-1");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("useChainGraphData", () => {
|
|
63
|
+
const makeGraph = () =>
|
|
64
|
+
({ setData: vi.fn() }) as unknown as ChainGraphServices & {
|
|
65
|
+
setData: ReturnType<typeof vi.fn>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type Props = {
|
|
69
|
+
getImage?: (img: string) => void;
|
|
70
|
+
labelRender?: (label: TreeGraphData) => string;
|
|
71
|
+
onLayoutingChange?: (v: boolean) => void;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
it("三个回调换引用都不会重新 setData", () => {
|
|
75
|
+
const graph = makeGraph();
|
|
76
|
+
const { rerender } = renderHook(
|
|
77
|
+
(props: Props) => useChainGraphData({ graph, data: DATA, ...props }),
|
|
78
|
+
{
|
|
79
|
+
initialProps: {
|
|
80
|
+
getImage: vi.fn(),
|
|
81
|
+
labelRender: vi.fn(() => "a"),
|
|
82
|
+
onLayoutingChange: vi.fn(),
|
|
83
|
+
} as Props,
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(graph.setData).toHaveBeenCalledTimes(1);
|
|
88
|
+
|
|
89
|
+
rerender({
|
|
90
|
+
getImage: vi.fn(),
|
|
91
|
+
labelRender: vi.fn(() => "b"),
|
|
92
|
+
onLayoutingChange: vi.fn(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(graph.setData).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("图实例回调时取到的是最新引用,不是首次 setData 时的那个", () => {
|
|
99
|
+
const graph = makeGraph();
|
|
100
|
+
const firstLabel = vi.fn(() => "first");
|
|
101
|
+
const { rerender } = renderHook(
|
|
102
|
+
(props: Props) => useChainGraphData({ graph, data: DATA, ...props }),
|
|
103
|
+
{
|
|
104
|
+
initialProps: {
|
|
105
|
+
getImage: vi.fn(),
|
|
106
|
+
labelRender: firstLabel,
|
|
107
|
+
onLayoutingChange: vi.fn(),
|
|
108
|
+
} as Props,
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const pushed = graph.setData.mock.calls[0][0] as {
|
|
113
|
+
getImage: (img: string) => void;
|
|
114
|
+
labelRender?: (label: TreeGraphData) => string;
|
|
115
|
+
isLayouting: (v: boolean) => void;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const nextImage = vi.fn();
|
|
119
|
+
const nextLabel = vi.fn(() => "next");
|
|
120
|
+
const nextLayouting = vi.fn();
|
|
121
|
+
rerender({
|
|
122
|
+
getImage: nextImage,
|
|
123
|
+
labelRender: nextLabel,
|
|
124
|
+
onLayoutingChange: nextLayouting,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
pushed.getImage("img");
|
|
128
|
+
expect(nextImage).toHaveBeenCalledWith("img");
|
|
129
|
+
|
|
130
|
+
expect(pushed.labelRender!(DATA)).toBe("next");
|
|
131
|
+
expect(firstLabel).not.toHaveBeenCalled();
|
|
132
|
+
|
|
133
|
+
act(() => pushed.isLayouting(false));
|
|
134
|
+
expect(nextLayouting).toHaveBeenCalledWith(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("labelRender 的「传了才改写 label」语义保留:没传就是 undefined,从无到有会带上", () => {
|
|
138
|
+
const graph = makeGraph();
|
|
139
|
+
const { rerender } = renderHook(
|
|
140
|
+
({ data, labelRender }: { data: TreeGraphData; labelRender?: Props["labelRender"] }) =>
|
|
141
|
+
useChainGraphData({ graph, data, labelRender }),
|
|
142
|
+
{ initialProps: { data: DATA } as { data: TreeGraphData; labelRender?: Props["labelRender"] } },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(graph.setData.mock.calls[0][0].labelRender).toBeUndefined();
|
|
146
|
+
|
|
147
|
+
const label = vi.fn(() => "x");
|
|
148
|
+
const nextData = { id: "root2", label: "root2" } as unknown as TreeGraphData;
|
|
149
|
+
rerender({ data: nextData, labelRender: label });
|
|
150
|
+
|
|
151
|
+
expect(graph.setData).toHaveBeenCalledTimes(2);
|
|
152
|
+
expect(typeof graph.setData.mock.calls[1][0].labelRender).toBe("function");
|
|
153
|
+
expect(graph.setData.mock.calls[1][0].labelRender(DATA)).toBe("x");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import { Equal } from "hsu-utils";
|
|
3
3
|
import ChainGraphServices from "../ChainGraphServices";
|
|
4
4
|
import { TreeGraphData } from "..";
|
|
5
|
+
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
5
6
|
|
|
6
7
|
interface UseChainGraphDataProps {
|
|
7
8
|
graph: ChainGraphServices | null;
|
|
@@ -28,6 +29,35 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
|
|
|
28
29
|
);
|
|
29
30
|
const [isLayouting, setIsLayouting] = useState(true);
|
|
30
31
|
|
|
32
|
+
// 这三个回调都是「交给图实例、由它在后续事件里回调」的出口,不是数据本身。
|
|
33
|
+
// 它们进依赖数组是条死依赖:effect 体被 ObjEqual(lastData, data) 挡住,
|
|
34
|
+
// 引用变了也不会重新 setData —— 图里存的还是首次 setData 时那个闭包,
|
|
35
|
+
// 换了引用永远调不到(stale closure)。改成引用恒定的转发函数:
|
|
36
|
+
// 依赖数组只留真正的输入(graph/data/level/rootLevel),调用时总是取到最新的回调。
|
|
37
|
+
const getImageRef = useLatestRef(getImage);
|
|
38
|
+
const labelRenderRef = useLatestRef(labelRender);
|
|
39
|
+
const onLayoutingChangeRef = useLatestRef(onLayoutingChange);
|
|
40
|
+
|
|
41
|
+
const emitImage = useCallback(
|
|
42
|
+
(img: string) => {
|
|
43
|
+
getImageRef.current?.(img);
|
|
44
|
+
},
|
|
45
|
+
[getImageRef]
|
|
46
|
+
);
|
|
47
|
+
// labelRender 在 ChainGraphServices.setData 里是「传了才改写 label」的真值判断,
|
|
48
|
+
// 所以这里保留「有没有传」的语义:没传就传 undefined,传了才给转发函数。
|
|
49
|
+
const renderLabel = useCallback(
|
|
50
|
+
(label: TreeGraphData) => labelRenderRef.current!(label),
|
|
51
|
+
[labelRenderRef]
|
|
52
|
+
);
|
|
53
|
+
const emitLayouting = useCallback(
|
|
54
|
+
(value: boolean) => {
|
|
55
|
+
setIsLayouting(value);
|
|
56
|
+
onLayoutingChangeRef.current?.(value);
|
|
57
|
+
},
|
|
58
|
+
[onLayoutingChangeRef]
|
|
59
|
+
);
|
|
60
|
+
|
|
31
61
|
useEffect(() => {
|
|
32
62
|
if (graph && !Equal.ObjEqual(lastData, data)) {
|
|
33
63
|
setLastData(data);
|
|
@@ -35,12 +65,9 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
|
|
|
35
65
|
data,
|
|
36
66
|
level,
|
|
37
67
|
rootLevel,
|
|
38
|
-
getImage,
|
|
39
|
-
isLayouting:
|
|
40
|
-
|
|
41
|
-
onLayoutingChange?.(value);
|
|
42
|
-
},
|
|
43
|
-
labelRender,
|
|
68
|
+
getImage: emitImage,
|
|
69
|
+
isLayouting: emitLayouting,
|
|
70
|
+
labelRender: labelRenderRef.current ? renderLabel : undefined,
|
|
44
71
|
});
|
|
45
72
|
}
|
|
46
73
|
}, [
|
|
@@ -48,10 +75,11 @@ export function useChainGraphData(props: UseChainGraphDataProps) {
|
|
|
48
75
|
data,
|
|
49
76
|
level,
|
|
50
77
|
rootLevel,
|
|
51
|
-
getImage,
|
|
52
|
-
labelRender,
|
|
53
|
-
onLayoutingChange,
|
|
54
78
|
lastData,
|
|
79
|
+
emitImage,
|
|
80
|
+
emitLayouting,
|
|
81
|
+
renderLabel,
|
|
82
|
+
labelRenderRef,
|
|
55
83
|
]);
|
|
56
84
|
|
|
57
85
|
return { isLayouting };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
2
|
import ChainGraphServices from "../ChainGraphServices";
|
|
3
|
+
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
3
4
|
|
|
4
5
|
interface UseChainGraphLayoutProps {
|
|
5
6
|
graph: ChainGraphServices | null;
|
|
@@ -11,9 +12,21 @@ interface UseChainGraphLayoutProps {
|
|
|
11
12
|
export function useChainGraphLayout(props: UseChainGraphLayoutProps) {
|
|
12
13
|
const { graph, octopus, rootLevel, getImage } = props;
|
|
13
14
|
|
|
15
|
+
// getImage 只是「布局完成后把截图 dataURL 交出去」的出口,不是布局的输入。
|
|
16
|
+
// 它进依赖数组会让消费方每传一次内联箭头就重新 changeLayout 一次 —— 整张图重排;
|
|
17
|
+
// 消费方再把截图存进 state,就变成「重排 → 出图 → setState → 新引用 → 再重排」的死循环。
|
|
18
|
+
// 用引用恒定的转发函数注册,调用时再取最新的 getImage。
|
|
19
|
+
const getImageRef = useLatestRef(getImage);
|
|
20
|
+
const emitImage = useCallback(
|
|
21
|
+
(img: string) => {
|
|
22
|
+
getImageRef.current?.(img);
|
|
23
|
+
},
|
|
24
|
+
[getImageRef],
|
|
25
|
+
);
|
|
26
|
+
|
|
14
27
|
useEffect(() => {
|
|
15
28
|
if (graph && octopus !== undefined) {
|
|
16
|
-
graph.changeLayout(octopus, rootLevel,
|
|
29
|
+
graph.changeLayout(octopus, rootLevel, emitImage);
|
|
17
30
|
}
|
|
18
|
-
}, [graph, octopus, rootLevel,
|
|
31
|
+
}, [graph, octopus, rootLevel, emitImage]);
|
|
19
32
|
}
|