
Народжений і виправлений за дванадцять хвилин
Changelog GeekBye v1.8.10 каже, що виправив збій під час редагування клавіатурних скорочень. Так і є — але збій був внесений і виправлений в одному й тому самому pull request, з різницею у дванадцять хвилин, і не дістався жодного користувача. Справжня історія — це каскад надійності, що його породив: маленька, правильна зміна того, коли скорочення призупиняються, і баг розбирання React, що випав з одного рядка, покликаного зробити редактор безпечнішим.
Ось найчесніше речення, яке я можу написати про GeekBye v1.8.10: збій, виправленням якого він славиться, ніколи ні з ким не стався. Changelog каже «Виправлено збій під час редагування клавіатурних скорочень і перемикання вікон посеред редагування», що є правдою і читається як баг, що потрапив у реліз, вкусив користувачів і був залатаний у наступному випуску. Це не так. Збій вніс один коміт, а видалив інший у тому самому pull request, з різницею у дванадцять хвилин, того самого ранку п'ятниці. Він жив цілком усередині гілки. Примітки до випуску описують шрам, що утворився і загоївся, перш ніж хтось поза репозиторієм міг його побачити.
Це не провал changelog; це чесна форма ітеративної роботи, і про неї варто розповісти, бо ті дванадцять хвилин містять по-справжньому повчальний баг React. Але щоб зрозуміти збій, треба зрозуміти, для чого реліз був насправді призначений, — бо збій був побічною шкодою від виправлення чогось іншого.
Про що реліз був насправді
GeekBye дозволяє перепризначати свої клавіатурні скорочення в Налаштуваннях. Поки ти записуєш нову комбінацію, застосунок мусить не дати спрацювати своїм глобальним скороченням, — інакше натискання Cmd+B, щоб його призначити, просто перемкнуло б вікно замість того, щоб бути захопленим. Тож редактор призупиняє глобальні скорочення через IPC: рендерер викликає window.electronAPI.setShortcutsSuspended(true), а ShortcutsHelper головного процесу відповідає викликом unregisterAll(), скидаючи кожну реєстрацію globalShortcut, тож натискання клавіші сягає захоплювального input замість того, щоб запустити дію. На відновленні він викликає 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>— рядок виходить із режиму редагування, тож input видаляється з DOM. - Видалення сфокусованого елемента з DOM синхронно надсилає подію
blurна цей елемент. Сирий слухачinputRefловить її і викликаєcancelEditing()знову — цього разу посеред фази коміту React, поки дерево компонентів розбирається. - Виклик сеттера стану на файбері, що посеред демонтажу, зачіпає один із внутрішніх інваріантів React, і він кидає:
"Should have a queue". Подання налаштувань падає.
Баг — це не слухач вікна і навіть не концепція «скасувати на blur». Річ у тім, що один із двох слухачів був сирим addEventListener на елементі під керуванням React. React не знає про того слухача, не прибирає його при демонтажі і — що критично — браузер вистрілює blur саме під час того видалення з DOM, яке виконує React, тож обробник повторно входить у твою логіку стану в найгірший можливий момент. Повідомлення коміту-виправлення каже це точніше, ніж можу я: «when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle.»
Виправлення
bf28a50, за дванадцять хвилин після народження збою, робить дві маленькі речі.
По-перше, воно видаляє сирий слухач input і лишає тільки віконний, який єдиний узагалі мав бути ручним слухачем DOM (немає React onBlur для «все вікно втратило фокус»):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
По-друге, воно переносить випадок кліку геть — «користувач клікнув інший елемент» — на власний проп React onBlur на input і відкладає його на такт:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Обидві частини важливі. Використання пропа React onBlur замість сирого слухача означає, що React володіє життєвим циклом обробника і не лишить його висіти. А setTimeout(…, 0) гарантує, що навіть коли цей blur справді спричинений демонтажем, виклик cancelEditing() приземляється на наступному такті — після того, як поточний рендеринг і коміт завершилися, — тож він ніколи не може оновити стан на компоненті, що досі розбирається. Якщо компонент на той час уже зник, відкладений виклик — нешкідлива пуста операція.
Це все виправлення. Видали слухача, якого React не бачив; дай React запланувати той, що лишився.
Чому це добрий різновид нудьги
Є спокуса віднести це до «тривіального» — setTimeout, видалений рядок — і рушити далі. Але його форма — це один із найпоширеніших способів, якими падають десктопні застосунки React, і його варто засвоїти:
- Збій з'являється лише на межі життєвого циклу (демонтаж), спричинений подією, яку фреймворк не планує (нативний blur під час видалення з DOM). Він не проявиться у клік-тесті; йому потрібен конкретний шлях «фокус іде геть під час редагування».
- Коренева причина — це змішування двох моделей володіння — сирих подій DOM і синтетичних, запланованих подій React — на одному елементі. Тієї миті, коли твій обробник змінює стан, що може демонтувати цей елемент, сирий шлях стає повторно входимим.
- Виправлення — це не «додати прапорець-охоронець» (хоча реф
isMountedзамаскував би це); це повне видалення сирого слухача, що є меншою зміною і правильною. Знову віднімання.
І мета-урок, той, довкола якого кружляє вся ця серія: читай дифф, а не примітку до випуску. «Виправлено збій під час редагування клавіатурних скорочень» точне, але приховує, що збій був самозаподіяний, того самого ранку, і ніколи не потрапив у реліз, — і що справжній реліз це тиха перемога коректності про те, коли призупиняти глобальне скорочення.
Що ще потрапило у реліз
Ще дві речі поїхали разом у v1.8.10, обидві варті рядка. Система скорочень також дістала самозцілення при засинанні/пробудженні: macOS може тихо скинути реєстрації глобальних скорочень застосунку, коли машина засинає, тож обробник відновлення тепер перевіряє і перереєстровує їх наново, тобто твої скорочення досі працюють після пробудження ноутбука без перезапуску. А на боці ШІ клієнт порозумнішав щодо лімітів частоти — потокові запити, що впираються у 429, тепер відступають експоненційно і скидають свій лічильник, коли ти зупиняєш запис, замість того щоб вичікувати фіксовану затримку. Три виправлення надійності в різних підсистемах, одне з яких — збій, що ніколи не був збоєм.
Про попередній розділ історії v1 — друк зустрічі у PDF без бібліотеки PDF (v1.8.9); а про всю дугу — анатомію доставки програмного забезпечення до досконалості.