
Рождён и исправлен за двенадцать минут
Changelog GeekBye v1.8.10 говорит, что исправлена авария при редактировании клавиатурных сочетаний. Так и есть — но авария была внесена и исправлена в одном и том же пул-реквесте, с разницей в двенадцать минут, и не добралась ни до одного пользователя. Настоящая история — это каскад надёжности, что её породил: маленькое, верное изменение того, когда сочетания приостанавливаются, и баг разборки React, что выпал из одной строки, призванной сделать редактор безопаснее.
Вот самое честное предложение, что я могу написать о GeekBye v1.8.10: авария, знаменитая тем, что её исправили, ни с кем не случилась. Changelog говорит «Исправлена авария при редактировании клавиатурных сочетаний и переключении окон посреди правки», и это правда, и читается как баг, что вышел, укусил пользователей и был залатан в последующем патче. Не было такого. Аварию внёс один коммит и удалил другой в том же пул-реквесте, с разницей в двенадцать минут, тем же пятничным утром. Она жила целиком внутри ветки. Примечания к релизу описывают шрам, что образовался и зажил прежде, чем кто-либо за пределами репозитория мог его увидеть.
Это не провал changelog; это честная форма итеративной работы, и о ней стоит рассказать, потому что эти двенадцать минут содержат по-настоящему поучительный баг React. Но чтобы понять аварию, надо понять, ради чего вообще был этот релиз, — потому что авария была побочным уроном от исправления кое-чего другого.
О чём на самом деле был этот релиз
GeekBye позволяет переназначать свои клавиатурные сочетания в Настройках. Пока ты записываешь новую комбинацию, приложение должно не дать своим глобальным сочетаниям сработать — иначе нажатие Cmd+B, чтобы его назначить, просто переключило бы окно вместо того, чтобы быть захваченным. Так что редактор приостанавливает глобальные сочетания через IPC: рендерер вызывает window.electronAPI.setShortcutsSuspended(true), а ShortcutsHelper главного процесса отвечает вызовом unregisterAll(), сбрасывая каждую регистрацию globalShortcut, чтобы нажатие клавиши дошло до поля захвата, а не запустило действие. При возобновлении он вызывает registerGlobalShortcuts(), чтобы вернуть их на место.
Баг, что всё это начал (issue #233), был про область этой приостановки. Старый код приостанавливал сочетания на всё время, пока была открыта страница настроек сочетаний, — а не только пока ты активно записывал клавишу. Большую часть времени это незаметно. Но если ты открыл страницу, начал делать что-то другое и ушёл, так и не закончив правку, приложение могло остаться со всеми своими глобальными сочетаниями незарегистрированными — тихо мёртвыми — пока ты не вернёшься. Коммит, что это исправляет, откровенен насчёт симптома: слишком широкая приостановка рисковала «сделать так, что приложение выглядит сломанным, если пользователь забыл, что у него открыта страница».
Исправление — однострочное сужение: приостанавливай только тогда, когда строка действительно редактируется, — завязано на !!editingShortcut, состоянии хука, что держит id записываемой строки, — вместо всего визита. Хорошее изменение. Но «приостанавливай только во время редактирования» рождает немедленный вопрос: что считается завершением правки? Нажатие клавиши её завершает. Нажатие Escape её отменяет. Но что, если ты просто… кликнешь в сторону или переключишься на другое приложение целиком, посреди захвата? Если ничто не отменяет правку, ты остаёшься записывающим сочетание в окно, что не в фокусе, а сочетания остаются приостановленными. Так что тот же PR добавил предохранитель: отменяй редактирование, когда фокус уходит. И это та строка, что упала.
Двенадцатиминутный баг
Предохранителем был useEffect в ShortcutsSettings.tsx, что, пока строка редактировалась, слушал blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Два слушателя. Один на window — срабатывает, когда ты переключаешься на другое приложение. Один прицеплен напрямую, как сырой слушатель DOM, к полю захвата <input> — призван срабатывать, когда ты кликаешь в сторону на другой элемент. Они выглядят избыточными и безобидными. Это не так.
Пройди последовательность для «переключения окон посреди правки», что и есть точная фраза из changelog:
- Ты записываешь сочетание;
<input>в фокусе. Ты жмёшь Cmd+Tab на другое приложение. - Срабатывает blur окна.
handleBlurзапускаетcancelEditing(), что ставитeditingShortcutобратно вnull. - Установка этого состояния размонтирует
<input>— строка выходит из режима правки, так что поле удаляется из DOM. - Удаление сфокусированного элемента из DOM синхронно отправляет событие
blurна этом элементе. Сырой слушательinputRefловит его и вызываетcancelEditing()снова — на этот раз посреди фазы коммита React, пока дерево компонентов разбирается. - Вызов сеттера состояния на файбере, что в разгаре разборки, задевает один из внутренних инвариантов React, и он выбрасывает:
"Should have a queue". Вид настроек падает.
Баг — не оконный слушатель и не сама по себе концепция «отмены по blur». Он в том, что один из двух слушателей был сырым addEventListener на управляемом React элементе. React не знает об этом слушателе, не чистит его при размонтировании и — что критично — браузер запускает blur во время того самого удаления из DOM, что выполняет React, так что обработчик заново входит в твою логику состояния в наихудший возможный миг. Сообщение исправляющего коммита говорит это точнее, чем могу я: «когда cancelEditing размонтирует поле, срабатывает blur и вызывает cancelEditing снова во время цикла рендеринга React».
Исправление
bf28a50, через двенадцать минут после рождения аварии, делает две маленькие вещи.
Во-первых, оно удаляет сырой слушатель поля и оставляет только оконный, что и был единственным, кому вообще нужно было быть ручным слушателем DOM (нет реактовского onBlur для «всё окно потеряло фокус»):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Во-вторых, оно переносит случай клика в сторону — «пользователь кликнул другой элемент» — на собственный проп onBlur React на поле и откладывает его на тик:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Обе части важны. Использование пропа onBlur React вместо сырого слушателя означает, что React владеет жизненным циклом обработчика и не оставит его болтаться. А setTimeout(…, 0) гарантирует, что даже когда этот blur вызван размонтированием, вызов cancelEditing() приземляется на следующий тик — после того как текущий рендер и коммит завершились, — так что он никогда не может обновить состояние на компоненте, что всё ещё разбирается. Если компонента к тому времени уже нет, отложенный вызов — безобидный no-op.
Вот и всё исправление. Удали слушатель, что React не мог видеть; дай React запланировать тот, что остался.
Почему это хороший сорт скуки
Есть соблазн отправить это в разряд «тривиального» — setTimeout, удалённая строка — и двигаться дальше. Но его форма — один из самых частых способов, какими падают десктопные приложения на React, и её стоит впитать:
- Авария появляется только на границе жизненного цикла (размонтирование), запущенная событием, что фреймворк не планирует (нативный blur во время удаления из DOM). Она не всплывёт в прощёлкивающем тесте; ей нужен конкретный путь «фокус уходит во время редактирования».
- Корневая причина — смешивание двух моделей владения — сырых событий DOM и синтетических, планируемых событий React — на одном элементе. В тот миг, когда твой обработчик меняет состояние, что может размонтировать этот элемент, сырой путь становится реентерабельным.
- Исправление — не «добавь флаг-охранник» (хотя ref
isMountedбы это замаскировал); это полное удаление сырого слушателя, что и есть меньшее изменение и верное. Снова вычитание.
И метаурок, тот, вокруг которого кружит вся эта серия: читай дифф, а не примечание к релизу. «Исправлена авария при редактировании клавиатурных сочетаний» — точно, но прячет, что авария была самонанесённой, тем же утром, и так и не вышла, — и что настоящий релиз это тихая победа корректности насчёт того, когда приостанавливать глобальное сочетание.
Что ещё вышло
Ещё две вещи проехались с v1.8.10, обе достойны строки. Система сочетаний также обрела самоисцеление через сон/пробуждение: macOS может тихо сбросить регистрации глобальных сочетаний приложения, когда машина засыпает, так что обработчик возобновления теперь заново их проверяет и регистрирует, а значит твои сочетания всё ещё работают после пробуждения ноутбука без перезапуска. А на стороне AI клиент поумнел насчёт лимитов запросов — потоковые запросы, что попадают на 429, теперь отступают экспоненциально и сбрасывают свой счётчик, когда ты останавливаешь запись, вместо того чтобы выжидать фиксированную задержку. Три исправления надёжности в разных подсистемах, одно из которых — авария, что никогда не была аварией.
Про предыдущую главу истории v1 — печать встречи в PDF без библиотеки PDF (v1.8.9); а про всю дугу — анатомию доведения софта до совершенства.