
十二分钟里诞生,又被修好
GeekBye v1.8.10 的 changelog 说它修好了一个编辑键盘快捷键时的崩溃。它确实修了——但那个崩溃是在同一个 pull request 里被引入又被修好的,前后相隔十二分钟,从没到达过哪怕一个用户。真正的故事是产生它的那场可靠性连锁反应:一个关于「何时挂起快捷键」的小而正确的改动,以及从一行本想让编辑器更安全的代码里掉出来的那个 React teardown bug。
关于 GeekBye v1.8.10,我能写下最诚实的一句话是:它以修好而出名的那个崩溃,从没在任何人身上发生过。changelog 说 "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit,",那是真的,读起来像一个发布了、咬到了用户、又在后续里被打了补丁的 bug。它不是。那个崩溃在同一个周五早上,在_同一个 pull request_ 里,前后相隔十二分钟,由一个 commit 引入、又被另一个 commit 删除。它整个只活在一个分支里。发布说明描述的,是一道在仓库之外的任何人能看见它之前,就已经结成又愈合的疤。
那不是 changelog 的失败;它是迭代工作诚实的形状,而值得一讲,是因为那十二分钟里装着一个真正有教益的 React bug。但要理解这个崩溃,你得先理解这次发布到底是_为了_什么——因为这个崩溃是修别的东西时的附带损害。
这次发布到底讲的是什么
GeekBye 让你在设置里重新绑定它的键盘快捷键。当你正在录制一个新组合时,应用必须阻止它的_全局_快捷键触发——否则你按 Cmd+B 想把它指派上去,那按键只会切换窗口,而不是被捕获。所以编辑器通过 IPC 挂起全局快捷键:渲染进程调用 window.electronAPI.setShortcutsSuspended(true),主进程的 ShortcutsHelper 以调用 unregisterAll() 来响应,丢掉每一个 globalShortcut 注册,好让这次按键抵达捕获输入框,而不是触发一个动作。恢复时它调用 registerGlobalShortcuts() 把它们放回去。
引发这一切的那个 bug(issue #233)关乎那次挂起的_范围_。旧代码在整个快捷键设置页面打开期间挂起快捷键——而不只是在你主动录制一个按键的时候。多数时候那看不见。但如果你打开了页面、开始做别的事、然后没完成任何一次编辑就导航离开了,那么直到你回来之前,应用都可能被留成全局快捷键全部未注册——悄无声息地死掉——的状态。修它的那个 commit 对症状很坦白:挂起得太宽,冒着 "making the app appear broken if the user forgot they had the page open." 的风险。
修复是一行的收窄:只在一行确实正被编辑时挂起——以 !!editingShortcut 为键,那是持有你正在录制的那一行 id 的 hook 状态——而不是在整次访问期间。好改动。但「只在编辑时挂起」立刻引出一个问题:什么算作_完成_一次编辑?按一个键完成它。按 Escape 取消它。可要是你就……点到别处去了,或者在捕获途中整个切到了另一个应用呢?如果没有任何东西取消这次编辑,你就被留着往一个没有焦点的窗口里录制快捷键,而快捷键保持挂起。所以同一个 PR 加了一个安全阀:焦点离开时取消编辑。而那,就是崩溃的那一行。
那个十二分钟的 bug
那个安全阀是 ShortcutsSettings.tsx 里的一个 useEffect,在一行正被编辑期间,监听 blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
两个监听器。一个在 window 上——当你切换到另一个应用时触发。一个_直接地、作为一个裸 DOM 监听器,_挂在捕获用的 <input> 上——本意是在你点击离开到另一个元素时触发。它们看起来冗余又无害。它们不是。
走一遍「中途切换窗口」的顺序,那正是 changelog 的那句 "switching windows mid-edit,":
- 你正在录制一个快捷键;
<input>有焦点。你 Cmd+Tab 到另一个应用。 - window 的 blur 触发。
handleBlur运行cancelEditing(),把editingShortcut设回null。 - 设置那个状态卸载了
<input>——那一行离开编辑模式,于是输入框从 DOM 里被移除。 - 把一个有焦点的元素从 DOM 里移除,会同步地在那个元素上派发一个
blur事件。 裸的inputRef监听器抓住它,又一次调用cancelEditing()——这一回是在 React 的 commit 阶段当中,就在组件树正被拆解的时候。 - 在一个处于拆解中途的 fiber 上调用一个状态 setter,绊到了 React 内部的一个不变式,于是它抛出:
"Should have a queue"。设置视图崩溃。
这个 bug 不是那个 window 监听器,也并不真的是「失焦时取消」这个概念。它是两个监听器里,有一个是挂在 React 托管元素上的裸 addEventListener。React 不知道那个监听器,不会在卸载时清理它,而且——关键在于——浏览器正是在 React 执行的那次 DOM 移除期间触发 blur 的,于是处理器在最糟糕的那一刻重新进入你的状态逻辑。修复 commit 的消息说得比我更精确:"when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
修复
bf28a50,在崩溃诞生之后十二分钟,做了两件小事。
第一,它删掉那个裸 input 监听器,只留下 window 那个,那是唯一一个压根就需要是一个手动 DOM 监听器的(对于「整个窗口失去了焦点」,并没有一个 React 的 onBlur):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
第二,它把点击离开这个情形——「用户点了另一个元素」——挪到 input 上 React 自己的 onBlur prop 上,并把它推迟一个 tick:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
两部分都重要。用 React 的 onBlur prop 而不是一个裸监听器,意味着 React 拥有这个处理器的生命周期,不会把它留着悬空。而 setTimeout(…, 0) 保证:即便这次 blur _确实_是被一次卸载触发的,cancelEditing() 调用也会落在下一个 tick——在当前渲染和 commit 都结束之后——所以它绝不可能在一个仍在拆解的组件上更新状态。如果到那时组件已经没了,那次推迟的调用就是一个无害的 no-op。
那就是整个修复。删掉 React 看不见的那个监听器;让 React 去调度剩下的那个。
为什么这是好的那种无聊
有一种诱惑,想把这个归到「琐碎」名下——一个 setTimeout、一行被删掉的代码——然后翻篇。但它的形状,是 React 桌面应用崩溃最常见的方式之一,值得内化:
- 崩溃只出现在一个生命周期边界(卸载)上,由一个框架不去调度的事件(DOM 移除期间的一次原生 blur)触发。它不会在一次点击流测试里冒出来;它需要「编辑时焦点离开」这条特定路径。
- 根因是在同一个元素上混用了两种所有权模型——裸 DOM 事件,和 React 合成的、被调度的事件。在你的处理器改动一个能卸载那个元素的状态的那一刻,裸的那条路径就变成可重入的了。
- 修复不是「加一个守卫标志」(尽管一个
isMountedref 本可以把它掩盖掉);它是把裸监听器整个移除,那才是_更小_的改动,也是正确的那个。又一次是减法。
还有那条元教训,这整个系列一直在绕的那条:读 diff,别读发布说明。"Fixed a crash while editing keyboard shortcuts" 是准确的,但它藏起了这些:那个崩溃是自找的、同一个早上的、从没发布出去的——以及真正的发布,是一个关于_何时_挂起一个全局快捷键的、安静的正确性胜利。
还发布了什么
另有两样东西搭着 v1.8.10 的车一起来了,都值得一行。快捷键系统还获得了跨睡眠/唤醒的自愈:macOS 会在机器睡眠时悄悄丢掉一个应用的全局快捷键注册,所以现在一个恢复处理器会重新核验并重新注册它们,意味着笔记本醒来之后,你的快捷键仍然能用,不用重启应用。而在 AI 这边,客户端对速率限制变聪明了——撞上一个 429 的流式请求现在会指数退避,并在你停止录制时重置它的计数器,而不是干等一个固定的延迟。三个针对不同子系统的可靠性修复,其中之一就是那个从来不是崩溃的崩溃。
关于 v1 故事的上一章,见不用 PDF 库把一场会议打印成 PDF(v1.8.9);整段弧线则见把软件打磨到完美地发布出去的解剖学。