
Narodzony i naprawiony w dwanaście minut
Changelog GeekBye v1.8.10 mówi, że naprawiono awarię podczas edycji skrótów klawiszowych. Owszem — ale awarię wprowadzono i naprawiono w tym samym pull requeście, dwanaście minut od siebie, i nigdy nie dotarła do ani jednego użytkownika. Prawdziwą historią jest kaskada niezawodności, która ją wytworzyła: mała, poprawna zmiana tego, kiedy skróty są zawieszane, i błąd rozbiórki Reacta, który wypadł z jednej linijki mającej uczynić edytor bezpieczniejszym.
Oto najuczciwsze zdanie, jakie potrafię napisać o GeekBye v1.8.10: awaria, z której naprawiania słynie, nigdy nikomu się nie przydarzyła. Changelog mówi „Naprawiono awarię podczas edycji skrótów klawiszowych i przełączania okien w trakcie edycji”, co jest prawdą i czyta się jak błąd, który trafił do wydania, ugryzł użytkowników i został załatany w poprawce. Tak nie było. Awarię wprowadził jeden commit, a usunął inny w tym samym pull requeście, dwanaście minut później, tego samego piątkowego poranka. Żyła w całości wewnątrz gałęzi. Notatki do wydania opisują bliznę, która powstała i zagoiła się, zanim ktokolwiek spoza repozytorium mógł ją zobaczyć.
To nie jest wina changelogu; to uczciwy kształt pracy iteracyjnej, i warto o tym opowiedzieć, bo te dwanaście minut zawiera naprawdę pouczający błąd Reacta. Ale żeby zrozumieć awarię, musisz zrozumieć, po co właściwie było to wydanie — bo awaria była szkodą uboczną naprawiania czegoś innego.
O co tak naprawdę chodziło w tym wydaniu
GeekBye pozwala zmieniać przypisania skrótów klawiszowych w Ustawieniach. Gdy nagrywasz nową kombinację, aplikacja musi powstrzymać swoje globalne skróty przed zadziałaniem — inaczej naciśnięcie Cmd+B, by je przypisać, po prostu przełączyłoby okno, zamiast zostać przechwycone. Więc edytor zawiesza globalne skróty przez IPC: renderer wywołuje window.electronAPI.setShortcutsSuspended(true), a ShortcutsHelper w procesie głównym odpowiada wywołaniem unregisterAll(), zrzucając każdą rejestrację globalShortcut, tak by naciśnięcie klawisza dotarło do pola przechwytywania, zamiast wyzwolić akcję. Przy wznowieniu wywołuje registerGlobalShortcuts(), by je przywrócić.
Błąd, który to wszystko zapoczątkował (zgłoszenie #233), dotyczył zakresu tego zawieszenia. Stary kod zawieszał skróty przez cały czas, gdy strona ustawień skrótów była otwarta — nie tylko wtedy, gdy aktywnie nagrywałeś klawisz. Przez większość czasu to niewidoczne. Ale jeśli otworzyłeś stronę, zacząłeś robić coś innego i przeszedłeś dalej, nigdy nie kończąc edycji, aplikacja mogła zostać z wszystkimi globalnymi skrótami wyrejestrowanymi — po cichu martwymi — dopóki nie wróciłeś. Commit, który to naprawia, jest szczery co do objawu: zbyt szerokie zawieszanie groziło „sprawieniem, że aplikacja wygląda na zepsutą, jeśli użytkownik zapomniał, że ma otwartą stronę”.
Poprawka to jednolinijkowe zawężenie: zawieszaj tylko wtedy, gdy wiersz jest faktycznie edytowany — oparte na !!editingShortcut, stanie hooka przechowującym id wiersza, który nagrywasz — zamiast na całą wizytę. Dobra zmiana. Ale „zawieszaj tylko podczas edycji” rodzi natychmiastowe pytanie: co liczy się jako zakończenie edycji? Naciśnięcie klawisza ją kończy. Naciśnięcie Escape ją anuluje. Ale co, jeśli po prostu… klikniesz gdzie indziej albo przełączysz się w środku przechwytywania na zupełnie inną aplikację? Jeśli nic nie anuluje edycji, zostajesz z nagrywaniem skrótu w oknie, które nie ma fokusu, a skróty pozostają zawieszone. Więc ten sam PR dodał zawór bezpieczeństwa: anuluj edycję, gdy fokus opuszcza okno. I to jest ta linijka, która wywołała awarię.
Dwunastominutowy błąd
Zaworem bezpieczeństwa był useEffect w ShortcutsSettings.tsx, który — podczas edycji wiersza — nasłuchiwał zdarzenia blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Dwa nasłuchy. Jeden na window — odpala się, gdy przełączasz się na inną aplikację. Jeden podpięty bezpośrednio, jako surowy nasłuch DOM, do pola przechwytywania <input> — miał odpalać się, gdy klikniesz na inny element. Wyglądają na zbędne i nieszkodliwe. Nie są.
Prześledź sekwencję dla „przełączania okien w trakcie edycji”, czyli dokładnie zwrotu z changelogu:
- Nagrywasz skrót;
<input>ma fokus. Robisz Cmd+Tab do innej aplikacji. - Odpala się blur okna.
handleBlururuchamiacancelEditing(), które ustawiaeditingShortcutz powrotem nanull. - Ustawienie tego stanu odmontowuje
<input>— wiersz opuszcza tryb edycji, więc pole jest usuwane z DOM. - Usunięcie sfokusowanego elementu z DOM synchronicznie wysyła zdarzenie
blurna tym elemencie. Surowy nasłuchinputRefłapie je i wywołujecancelEditing()ponownie — tym razem w środku fazy commit Reacta, gdy drzewo komponentów jest rozbierane. - Wywołanie settera stanu na fiberze będącym w trakcie rozbiórki narusza jeden z wewnętrznych inwariantów Reacta i rzuca:
"Should have a queue". Widok ustawień się wysypuje.
Błędem nie jest nasłuch okna i nie jest nim tak naprawdę koncepcja „anuluj przy blur”. Jest nim to, że jeden z dwóch nasłuchów był surowym addEventListener na elemencie zarządzanym przez React. React nie wie o tym nasłuchu, nie sprząta go przy odmontowaniu i — co kluczowe — przeglądarka odpala blur w trakcie właśnie tego usuwania z DOM, które React wykonuje, więc handler ponownie wchodzi w twoją logikę stanu w najgorszym możliwym momencie. Komunikat commita naprawiającego mówi to precyzyjniej niż ja potrafię: „gdy cancelEditing odmontowuje pole, odpala się blur i wywołuje cancelEditing ponownie podczas cyklu renderowania Reacta”.
Poprawka
bf28a50, dwanaście minut po narodzinach awarii, robi dwie małe rzeczy.
Po pierwsze, usuwa surowy nasłuch pola i zostawia tylko ten na oknie, który jako jedyny w ogóle musiał być ręcznym nasłuchem DOM (nie ma reactowego onBlur dla „całe okno straciło fokus”):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Po drugie, przenosi przypadek kliknięcia gdzie indziej — „użytkownik kliknął inny element” — na własny prop onBlur Reacta na polu i odracza go o tik:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Obie części mają znaczenie. Użycie propa onBlur Reacta zamiast surowego nasłuchu oznacza, że React jest właścicielem cyklu życia handlera i nie zostawi go wiszącego. A setTimeout(…, 0) gwarantuje, że nawet gdy ten blur jest wyzwolony przez odmontowanie, wywołanie cancelEditing() ląduje w następnym tiku — po zakończeniu bieżącego renderu i commita — więc nigdy nie może zaktualizować stanu na komponencie, który wciąż się rozbiera. Jeśli komponentu już wtedy nie ma, odroczone wywołanie jest nieszkodliwym no-opem.
To cała poprawka. Usuń nasłuch, którego React nie widział; pozwól Reactowi zaplanować ten, który został.
Dlaczego to ten dobry rodzaj nudy
Jest pokusa, by wrzucić to do szufladki „trywialne” — setTimeout, usunięta linijka — i iść dalej. Ale jego kształt to jeden z najczęstszych sposobów, w jaki wysypują się reactowe aplikacje desktopowe, i warto go sobie przyswoić:
- Awaria pojawia się tylko na granicy cyklu życia (odmontowanie), wyzwolona przez zdarzenie, którego framework nie planuje (natywny blur podczas usuwania z DOM). Nie pokaże się w teście przeklikiwania; potrzebuje konkretnej ścieżki „fokus opuszcza okno podczas edycji”.
- Przyczyną źródłową jest mieszanie dwóch modeli własności — surowych zdarzeń DOM i syntetycznych, planowanych zdarzeń Reacta — na tym samym elemencie. W momencie, gdy twój handler mutuje stan, który może odmontować ten element, surowa ścieżka staje się ponownie wejściowa.
- Poprawką nie jest „dodaj flagę zabezpieczającą” (choć ref
isMountedby to zamaskował); to całkowite usunięcie surowego nasłuchu, co jest mniejszą zmianą i tą właściwą. Znowu odejmowanie.
A metalekcja, ta, wokół której krąży cała ta seria: czytaj diff, nie notatkę do wydania. „Naprawiono awarię podczas edycji skrótów klawiszowych” jest trafne, ale ukrywa, że awaria była samozadana, z tego samego poranka i nigdy nie trafiła do wydania — oraz że prawdziwym wydaniem jest ciche zwycięstwo poprawności co do tego, kiedy zawiesić globalny skrót.
Co jeszcze trafiło do wydania
Dwie kolejne rzeczy załapały się do v1.8.10, obie warte linijki. System skrótów zyskał też samonaprawę przez sen/wybudzenie: macOS może po cichu zrzucić rejestracje globalnych skrótów aplikacji, gdy maszyna zasypia, więc handler wznowienia teraz je ponownie weryfikuje i rejestruje, co znaczy, że twoje skróty wciąż działają po wybudzeniu laptopa bez ponownego uruchamiania. A po stronie AI klient zmądrzał w kwestii limitów zapytań — żądania strumieniowe, które trafią na 429, teraz wycofują się wykładniczo i resetują swój licznik, gdy zatrzymasz nagrywanie, zamiast odczekiwać stałe opóźnienie. Trzy poprawki niezawodności w różnych podsystemach, z których jedna to awaria, która nigdy nie była awarią.
Po poprzedni rozdział historii v1 — drukowanie spotkania do PDF bez biblioteki PDF (v1.8.9); a po cały łuk — anatomia dostarczania oprogramowania do perfekcji.