
Роден и поправен за дванадесет минути
Changelog-ът на GeekBye v1.8.10 казва, че е поправил срив при редактиране на клавишните комбинации. И наистина го поправи — но сривът беше въведен и поправен в един и същ pull request, на дванадесет минути разстояние, и никога не достигна до нито един потребител. Истинската история е каскадата от надеждност, която го произведе: малка, коректна промяна в това кога комбинациите се спират, и бъгът с React разграждането, който изпадна от един ред, предназначен да направи редактора по-безопасен.
Ето най-честното изречение, което мога да напиша за GeekBye v1.8.10: сривът, с чието поправяне е известно, никога не се случи на никого. Changelog-ът казва "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," което е вярно и се чете като бъг, който се е пуснал, ухапал е потребители и е бил закърпен в следваща версия. Не беше. Сривът беше въведен от един commit и изтрит от друг в същия pull request, на дванадесет минути разстояние, в същата петъчна сутрин. Той живя изцяло вътре в един branch. Бележките към изданието описват белег, който се образува и заздравя, преди някой извън репото да можеше да го види.
Това не е провал на changelog-а; това е честната форма на итеративната работа, и си струва да се разкаже, защото дванадесетте минути съдържат наистина поучителен React бъг. Но за да разбереш срива, трябва да разбереш за какво всъщност беше изданието — защото сривът беше странична щета от поправянето на нещо друго.
За какво всъщност беше изданието
GeekBye ти позволява да преназначиш клавишните му комбинации в Настройки. Докато записваш нова комбинация, приложението трябва да спре своите глобални комбинации да се задействат — иначе натискането на Cmd+B, за да я зададеш, просто би превключило прозореца, вместо да бъде прихванато. Затова редакторът спира глобалните комбинации през IPC: renderer-ът извиква window.electronAPI.setShortcutsSuspended(true), а ShortcutsHelper на главния процес отговаря, като извиква unregisterAll(), изхвърляйки всяка регистрация на globalShortcut, така че натискането на клавиш да достигне input-а за прихващане, вместо да задейства действие. При възобновяване извиква registerGlobalShortcuts(), за да ги върне обратно.
Бъгът, който започна всичко това (issue #233), беше за обхвата на това спиране. Старият код спираше комбинациите за цялото време, докато страницата с настройки на комбинациите беше отворена — не само докато активно записваше клавиш. През повечето време това е невидимо. Но ако отвореше страницата, започнеше да правиш нещо друго и се придвижеше другаде, без изобщо да завършиш редакция, приложението можеше да остане с всички свои глобални комбинации нерегистрирани — мъртви в тишина — докато не се върнеше. Commit-ът, който го поправя, е откровен за симптома: прекалено широкото спиране рискуваше "making the app appear broken if the user forgot they had the page open."
Поправката е стесняване от един ред: спирай само когато ред действително се редактира — на базата на !!editingShortcut, състоянието на hook-а, което държи id-то на реда, който записваш — вместо за цялото посещение. Добра промяна. Но „спирай само докато редактираш" повдига незабавен въпрос: какво се брои за завършване на редакция? Натискането на клавиш я завършва. Натискането на Escape я отменя. Но какво става, ако просто… кликнеш настрани или превключиш изцяло към друго приложение по средата на прихващането? Ако нищо не отмени редакцията, оставаш да записваш комбинация в прозорец, който не е фокусиран, и комбинациите остават спрени. Затова същият PR добави предпазен клапан: отмени редактирането, когато фокусът напусне. И това е редът, който предизвика срив.
Бъгът от дванадесет минути
Предпазният клапан беше useEffect в ShortcutsSettings.tsx, който, докато ред се редактираше, слушаше за blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Два listener-а. Един на window — задейства се, когато превключиш към друго приложение. Един закачен директно, като raw DOM listener, на <input>-а за прихващане — предназначен да се задейства, когато кликнеш настрани към друг елемент. Изглеждат излишни и безобидни. Не са.
Проследи последователността за "switching windows mid-edit," което е точно фразата от changelog-а:
- Записваш комбинация;
<input>-ът е фокусиран. Правиш Cmd+Tab към друго приложение. - Blur-ът на прозореца се задейства.
handleBlurизпълняваcancelEditing(), който връщаeditingShortcutобратно наnull. - Задаването на това състояние демонтира
<input>-а — редът напуска режима на редактиране, така че input-ът се премахва от DOM. - Премахването на фокусиран елемент от DOM синхронно изпраща
blurсъбитие върху този елемент. RawinputReflistener-ът го улавя и извикваcancelEditing()отново — този път по средата на commit фазата на React, докато дървото от компоненти се разгражда. - Извикването на setter на състоянието върху fiber, който е по средата на разграждането, задейства един от вътрешните инварианти на React, и той хвърля:
"Should have a queue". Изгледът с настройки се срива.
Бъгът не е listener-ът на прозореца и не е наистина концепцията за „отмяна при blur". Проблемът е, че един от двата listener-а беше raw addEventListener върху управляван от React елемент. React не знае за този listener, не го почиства при демонтиране и — което е ключово — браузърът задейства blur точно по време на самото премахване от DOM, което React извършва, така че handler-ът повторно влиза в твоята логика на състоянието в най-лошия възможен момент. Съобщението на commit-а с поправката го казва по-точно, отколкото мога аз: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
Поправката
bf28a50, дванадесет минути след като сривът се роди, прави две малки неща.
Първо, тя изтрива raw input listener-а и запазва само този на прозореца, който е единственият, който изобщо трябваше да бъде ръчен DOM listener (няма React onBlur за „целият прозорец загуби фокус"):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Второ, тя премества случая с клик-настрани — „потребителят кликна върху друг елемент" — върху собствения onBlur prop на React на input-а, и го отлага с един tick:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
И двете части имат значение. Използването на onBlur prop-а на React вместо raw listener означава, че React притежава жизнения цикъл на handler-а и няма да го остави да виси. А setTimeout(…, 0) гарантира, че дори когато този blur е задействан от демонтиране, извикването на cancelEditing() попада на следващия tick — след като текущите render и commit са приключили — така че никога не може да актуализира състоянието върху компонент, който все още се разгражда. Ако компонентът вече е изчезнал дотогава, отложеното извикване е безобиден no-op.
Това е цялата поправка. Изтрий listener-а, който React не можеше да види; остави React да планира този, който остана.
Защо това е добрият вид скука
Има изкушение това да се сложи под „тривиално" — един setTimeout, един изтрит ред — и да се продължи нататък. Но формата му е един от най-честите начини, по които React desktop приложенията се сриват, и си струва да се интернализира:
- Сривът се появява само на граница на жизнения цикъл (демонтиране), задействан от събитие, което framework-ът не планира (нативен blur по време на премахване от DOM). Няма да се появи в click-through тест; нуждае се от специфичния път „фокусът напуска по време на редактиране".
- Кореновата причина е смесването на два модела на собственост — raw DOM събития и синтетичните, планирани събития на React — върху един и същ елемент. В момента, в който твоят handler променя състояние, което може да демонтира този елемент, raw пътят става реентрантен.
- Поправката не е „добави флаг за защита" (макар че
isMountedref би го маскирал); тя е пълното премахване на raw listener-а, което е по-малката промяна и правилната. Отново изваждане.
А мета-урокът, онзи, около който цялата тази поредица продължава да обикаля: чети diff-а, не бележката към изданието. "Fixed a crash while editing keyboard shortcuts" е точно, но крие, че сривът беше самопричинен, в същата сутрин, и никога не се пусна — и че истинското издание е тиха победа на коректността за това кога да спреш глобална комбинация.
Какво друго се пусна
Още две неща дойдоха на автостоп в v1.8.10, и двете заслужават по един ред. Системата за комбинации също така придоби самолечение през sleep/wake: macOS може тихо да изпусне регистрациите на глобалните комбинации на приложение, когато машината заспи, така че handler за възобновяване сега ги препроверява и пререгистрира, което означава, че комбинациите ти все още работят, след като лаптопът се събуди, без повторно стартиране. А от страната на AI, клиентът стана по-умен относно limit-ите на честотата — стрийминг заявките, които удрят 429, сега се оттеглят експоненциално и нулират брояча си, когато спреш записа, вместо да изчакват фиксирано забавяне. Три поправки на надеждността в различни подсистеми, една от които е сривът, който никога не беше срив.
За предишната глава от историята на v1 — отпечатване на среща в PDF без PDF библиотека (v1.8.9); а за цялата дъга — анатомията на доставянето на софтуер до съвършенство.