移植自本地 main(含 3536689..e670f6f 的最终状态)。
基于 origin/main 而非 rebase 本地 —— 本地那条链上的 6 个提交
(0a9287b/d52be3f/74aacc3/646cb3f/6e198d2/010c8ec)是远端已推内容的等价改写,
重复移植只会制造冲突;远端另有一条线上的成果(main.cjs/preload.cjs/
index.css/main.tsx/TitleBar.tsx/run-all.mjs 与 4 份 docs)本地没有,
**不能覆盖**,故从 origin/main 起只取本次真正改动的 5 个文件。
## 对话树:换成画布式真树
用户连续三轮反馈「这不还是老版的对话树」,最后明确要求:
「直接用你之前写的那个**可拖动缩放的对话树界面**替换现在这个对话树界面」。
我前两轮改错了方向 —— 一直在**缩进列表**上打补丁(改排序 → 改竖线 →
整份重写),而缩进列表**表达不了分支**:实测最深的线索 205 封、59 层,
多个兄弟追问并排时缩进线断成一片;画布上每条边都是独立的一条线。
现在:可拖动平移 / 滚轮缩放(20%–250%,锚定光标)/ 点节点看正文 /
分层 Reingold–Tilford 布局(不用力导向:位置会变、点不准)。
## 两个线上 bug(用户实测报告,我第一轮没看出来)
① **页面没撑满**:打开后 zoom=140%、pan.x=-9393,内容包围盒 11340×7193,
屏幕上只看得到左上角。而手动点「适应」是对的(0.1217)——
同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。
逐层实测揪出三个连环坑:`nodes` 是**根数组**(只 1 个根)、
自动 fit 的 effect 没在数据到达后重跑、`fittedFor` 守卫置位太早把自己挡掉。
另:`MailView` 的 `threadOf` 分支裸返回 `<ThreadView/>`,缺
`flex-1 min-w-0 min-h-0` ⇒ 高度链断。
② **一缩放就崩**:`onWheel` 直接解引用 `e.currentTarget`,
组件卸载后滚轮事件仍在飞 ⇒ `TypeError: Cannot read properties of null`。
实测放大 20 次后面板消失。改成先取可空引用 + 空值守卫。
真机复测:200 节点 / 55 层,打开即 fit 到 12%,放大 20 次 + 缩小 25 次
全部 0 崩溃 0 错误。
## 日历:翻月卡死
用户实测「点击切换月份的按钮,直接从10月跳到八月卡死」。
CDP 复现:
第1跳起:opacity="0",transform="translateX(-94.8px)",元素 x = -15
右侧工具栏(导入/导出/新建)整块消失
根因:注释写着「用**声明式** key + 类」,实现却是两个 ref ——
**ref 改动不触发重渲染** ⇒ slideClass 只能用上一次的方向,且 class 名永不变
⇒ 动画只播一次不再重放。而 `animation-fill-mode: both` 把元素留在帧之间,
动画一旦没跑到终点就永久停在 `translateX(-12%) + opacity:0`。
修:ref → `useState<{dir,seq}>`;`seq` 进 key 让动画真重放;
加 260ms 自清理兜底(最坏只是没动画,不会卡死)。
复测:12 次连点,每次都是「class 已摘掉 OK」,侧栏/网格/工具栏全好。
## 判据
`thread-view-tree.test.mjs` 15 格 + `calendar-slide.test.mjs` 6 格,
每个 bug 都有对应的**可复现变异**(退回 ref / 去掉清理 / 去掉 seq …),
实测全部转红。
## 教训
两轮都只跑 `tsc` + 判据就宣布完成,然后请用户去看 ——
**「类型没错」不等于「页面能用」**。日历那一轮我更是盲巡检了十几轮
(还把「看起来被切」误判成 bug,最后自己量出 scrollHeight==clientHeight 才撤回)。
**先问清楚,比继续猜便宜得多。**
62 lines
3.2 KiB
JavaScript
62 lines
3.2 KiB
JavaScript
/*
|
||
Electron 日历页的判据(2026-10-04)。
|
||
|
||
★ 起因:用户实测报告「点击切换月份的按钮,直接从10月跳到八月卡死」。
|
||
我用 CDP 真机复现时看到的是:
|
||
animation-play-state: running,opacity: 0,
|
||
transform: translateX(-94.8px),元素 x = -15(跑出视口)
|
||
⇒ 日历整块滑出屏幕且**永不回来**。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { code } from './lib/read.mjs';
|
||
|
||
const src = p => code(`src/${p}`);
|
||
|
||
test('★ 翻页动画状态必须是 state,不能是 ref', () => {
|
||
const cal = src('components/CalendarView.tsx');
|
||
// ★ 核心:注释写着「用**声明式** key + 类」,实现却是命令式 ref。
|
||
// ref 改动不触发重渲染 ⇒ slideClass 只能用上一次的方向,
|
||
// 且 class 名永不变 ⇒ 动画只播一次就停在起始帧 ⇒ 观感「卡死」。
|
||
assert.match(cal, /const \[slide, setSlide\] = useState/,
|
||
'★ 翻页动画状态还在用 ref —— ref 改动不触发重渲染,动画会卡在起始帧');
|
||
assert.doesNotMatch(cal, /navigated\.current|slideDir\.current/,
|
||
'★ 还在用 navigated/slideDir ref(命令式)—— 与注释宣称的「声明式」矛盾');
|
||
});
|
||
|
||
test('★ 每次翻页都要重放一次动画(key 含 seq)', () => {
|
||
const cal = src('components/CalendarView.tsx');
|
||
assert.match(cal, /seq: \(s\?\.seq \?\? 0\) \+ 1/, 'seq 没有递增');
|
||
assert.match(cal, /key=\{`\$\{anchor\.getTime\(\)\}-\$\{scale\}-\$\{slide\?\.seq \?\? 0\}`\}/,
|
||
'★ key 不含 seq —— 同方向连续翻页时动画可能不重放');
|
||
});
|
||
|
||
test('★ 动画结束必须摘掉 class(fill-mode:both 的兜底)', () => {
|
||
const cal = src('components/CalendarView.tsx');
|
||
// ★ animation-fill-mode: both 会把元素留在帧之间。动画一旦没跑到终点
|
||
// (连点 / 切后台 / reduced-motion 切换),元素就永久停在
|
||
// translateX(-12%) + opacity:0 —— 实测元素 x=-15,整块日历跑出视口。
|
||
assert.match(cal, /setTimeout\(\(\) => setSlide\(null\)/,
|
||
'★ 动画结束后没有清掉 class —— 元素会永久停在动画帧上(用户实测「卡死」)');
|
||
});
|
||
|
||
test('翻页方向只有一个来源(按钮与手势共用 shift)', () => {
|
||
const cal = src('components/CalendarView.tsx');
|
||
assert.match(cal, /function shift\(dir: 1 \| -1\)/, '没有统一的 shift()');
|
||
const n = (cal.match(/shift\(/g) || []).length;
|
||
assert.ok(n >= 3, `shift 只有 ${n} 处引用,按钮/手势可能各写一套`);
|
||
});
|
||
|
||
test('翻页用函数式更新(连点不丢更新)', () => {
|
||
const cal = src('components/CalendarView.tsx');
|
||
assert.match(cal, /setAnchor\(a => addMonths\(a, dir\)\)/,
|
||
'★ setAnchor 没用函数式更新 —— 连点时会基于同一个旧 anchor 算,跳月会丢步');
|
||
assert.match(cal, /addDays\(a, dir \* 7\)/, '周视图没有按 7 天翻页');
|
||
});
|
||
|
||
test('addMonths 先归到 1 号(setMonth 会溢出)', () => {
|
||
const lib = src('lib/calendar.ts');
|
||
assert.match(lib, /new Date\(d\.getFullYear\(\), d\.getMonth\(\) \+ n, 1\)/,
|
||
'★ addMonths 没有先归到 1 号 —— 1月31日加一月会溢出到 3 月');
|
||
}); |