perf(dashboard): 会话四视图去掉整树重渲染,并修 listen 探测的 close 死锁 - #1378
Conversation
会话控制页在 5000+ 会话下,点任意一个按钮都要卡好几秒。根因有三处,都是
「只改了一点状态,却重建/重算整棵树」:
1. 视图切换靠 `key={viewStageAnimKey}` 换 key 来重放入场动画,等于每次点
「看板/状态板/话题/表格」都把当前视图连同全部卡片从零重建。改成就地重启
CSS 动画(摘 class → 读 offsetWidth → 加回),DOM 不动。
四个视图同时也从 `hidden` 改为按需挂载,非当前视图不再留在树里。
2. 表格/状态板/话题三个视图完全没有 memo,且每个回调都是 JSX 上的内联箭头。
于是「打开/关闭详情」这种只改抽屉状态的操作,也要重排上千行 DOM。三个视图
统一 memo 化,回调全部收进 useCallback 固定引用。
注意 memo 会挡住父组件触发的重渲染,而这三个视图内部直接调 t(),所以每个
都补了 useT() 自己订阅 locale——否则切语言时文案会停在旧语言上。
3. 看板卡片同理:卡片是唯一按会话数线性增长的东西(bot 分组下 1406 张),
选中一张卡片却会让所有卡片重算一遍。按「这张卡片会不会长得不一样」写了
等价函数,并把 icons 对象提到模块级、cardProps 收进 useMemo。
顺带修掉 listen-with-probe 的一个静默死锁:verifyBound 拒绝某端口后走
releaseAndStep,而 server.close() 只停止 accept、会等已接受的连接排空,
步进与日志又都在它的回调里。于是一个赖着不走的连接就能让整个探测无声卡死——
没有 LISTEN、不步进到下一个端口、一行日志都没有。这正是 dashboard 前几天
起不来的原因。加 closeAllConnections() 先拆连接。
影响面:改动集中在 dashboard 前端 sessions 页与 listen-with-probe。
listen-with-probe 被 dashboard / dashboard-ipc 共用,已跑相关测试;
未触及 daemon、worker、适配器、IM 层。
验证:
- bun run build 干净;相关 8 个测试文件 84 passed
- listen-with-probe 死锁 A/B(node 22 与 bun 各自实测):
修复前 close() 回调 10s 仍未触发,修复后 0~1ms;新增回归测试,
变异源码即精准转红(Test timed out in 4000ms)
- memo 引入的 locale 风险做了反变异:删掉话题视图的 useT() 后,切语言时
话题停在旧语言而另外两个视图正常;恢复后三个视图全部跟随
- 真机(5800 会话)前后对比见 PR 描述
Co-Authored-By: Claude Code <noreply@anthropic.com>
自动评审初步意见先说结论:核心方向和 listen 探测那条修复都很好,建议合入前补两处。下面两条都做了 master 对照实验,不是静态读码推测。
建议修改 1:切换语言后,看板卡片文案不跟着变
新比较器的每一项判据( 真组件实测(
触发路径就是顶栏那个 CN/EN 按钮,不算边角场景。 建议改法(1 行 + 1 个 import): function KanbanCardBase(props: {...}): React.JSX.Element {
// 卡片被 memo 且比较器不看 locale,必须自己订阅,否则切语言时文案停在旧语言。
useT();
const { callbacks, row } = props;建议修改 2:名字表异步回来后,卡片停在
|
| 卡片显示 | |
|---|---|
| 本 PR | cli_app123(不变) |
| master | 正确换成 FriendlyBot |
本 PR + useT() |
cli_app123(仍不变) |
冷加载(localStorage 无缓存)且用户上次停在看板视图时会直接命中。
建议改法(3 行):把页面已有的 revision 透传进比较器判据。
// SessionsKanbanCallbacks
/** 名字表解析完成后自增:卡片被 memo 且行对象引用不变,没有这个判据
* 名字表回来后卡片会一直停在 cli_xxx。 */
namesVersion?: number;
// 比较器首段
if (a.namesVersion !== b.namesVersion) return false;
// 调用点
<SessionsKanbanView namesVersion={revision} … />两处修法都不会牺牲这个 PR 的性能收益(N=200 张卡片,选中其中 1 张,统计卡片重渲染次数):
| 重渲染次数 | |
|---|---|
| master | 400 |
| 本 PR | 1 |
| 本 PR + 上述两处修改 | 1 |
改完 tsc 0 错,相关 6 个测试文件 189/189 绿。
另有一条非阻断,供参考
安静会话的相对时间会冻住:一个长时间没有任何更新的会话,在页面因为别的会话重渲染时,本 PR 仍显示「更新于 刚刚」,master 会正确变成「更新于 10m」。因为 store 是 {...cur, ...patch} 换新对象,自己有更新的会话不受影响,只有"完全安静但页面在重绘"的卡片会这样。时间戳本来就是近似值,master 也只是搭便车刷新,所以我倾向于不阻断,列出来供判断。
以上是自动评审的初步意见,可能有误判,最终以维护者审阅为准。条件挂载、ClusterView、kanbanHost 的 ref 处理、入场动画这些我也都过了一遍,没发现问题;expandedClusters 切视图清空这点在 master 上同样存在(master 靠 key={viewStageAnimKey} 整棵重挂),不算本 PR 引入。
改了什么
会话控制页(Dashboard → 会话控制)在 5800 会话的真实数据下,点任意一个按钮都要卡好几秒。
根因有三处,形状一致:只改了一点状态,却重建/重算了整棵树。
1. 视图切换靠换 key 重放入场动画 ⟹ 每次切视图都把整个视图从零重建
原来入场动画是
key={viewStageAnimKey}驱动的——换 key 等于告诉 React「这是另一个组件」,于是点一次「看板/状态板/话题/表格」,当前视图连同全部卡片全部卸载再挂载。
改成就地重启 CSS 动画:摘掉 class → 读一次
offsetWidth触发 reflow → 加回去。DOM 不动,React 不重建子树。四个视图同时从
hidden={...}改为按需挂载,非当前视图不再留在树里(切完一轮后 DOM 节点从 460099 → 11546)。2. 表格 / 状态板 / 话题三个视图完全没有 memo,回调全是 JSX 内联箭头
于是「打开/关闭详情」这种只改抽屉状态的操作,也要把上千行 DOM 重排一遍。
三个视图统一 memo 化,回调收进
useCallback固定引用。3. 看板卡片:选中一张卡片会让所有卡片重算
卡片是唯一按会话数线性增长的东西(bot 分组下 1406 张)。按「这张卡片会不会长得不一样」写了
等价函数(
dragId/dropBeforeId/editingId折成布尔、callbacks逐字段比),并把 icons 对象提到模块级、
cardProps收进useMemo。顺带:修掉 listen-with-probe 的一个静默死锁
verifyBound拒绝某端口后走releaseAndStep,而server.close()只停止 accept、会等已接受的连接排空,偏偏步进与日志都在它的回调里。于是一个赖着不走的连接就能让整个探测无声卡死——
没有 LISTEN、不步进到下一个端口、一行日志都没有。加
closeAllConnections()先拆连接。为什么
用户反馈「看板」卡,修完看板后又反馈「状态板、话题、表格中还是有些卡,主要体现在点击某些按钮时,例如『详情』的打开和关闭」。
这三处根因不同但形状相同,一起修。
效果(真机 5800 会话,Playwright + PerformanceObserver,取三次中位数)
详情抽屉开/关 — 这是用户直接反馈的场景:
视图切换 / 看板分组切换:
影响面
sessions-page.tsx/sessions-kanban.tsx)与utils/listen-with-probe.tslisten-with-probe被 dashboard 与 dashboard-ipc 共用,已跑两边相关测试验证
listen-with-probe 死锁 A/B(node 22 与 bun 各自实测):修复前
close()回调 10s 仍未触发;修复后 0~1ms。新增回归测试「steps up even when a client is still parked on the rejected port」,
变异源码即精准转红(
Test timed out in 4000ms)。memo 引入的 locale 风险做了反变异:单独删掉话题视图的
useT()后,切语言时话题视图停在旧语言("Details"),而状态板/表格正常切到「详情」;恢复后三个视图全部跟随。证明这三个
useT()是承重的,不是冗余。表格列显示/隐藏功能回归:13 → 12 → 13 列,持久化
["cliId"]→[]正确;两列隐藏后重置也正确。入场动画仍然会重放:挂
animationstart监听,每次切视图计数都 +1,看板分组切换 6 → 7。本次不处理的残留开销(已测量,留给后续)
改完后剩下的耗时基本不在 React 层了。CDP profile(话题视图开详情):
showModal706ms(19.5%) — 浏览器原生 top-layer 提升getBoundingClientRect490ms(13.5%) — 来自floating-scrollbars.ts的readTargets()全树扫描:话题视图 119502 个节点扫 355ms,只为找出 5 个可滚动元素
这两项要么需要虚拟化、要么需要把观察范围收窄到子树,属于另一个量级的改动,本 PR 刻意不碰。
界面截图
🤖 Generated with Claude Code