
Född och fixad på tolv minuter
GeekBye v1.8.10:s ändringslogg säger att den fixade en krasch vid redigering av tangentbordsgenvägar. Det gjorde den — men kraschen introducerades och fixades i samma pull request, tolv minuter isär, och nådde aldrig en enda användare. Den verkliga historien är tillförlitlighetskaskaden som producerade den: en liten, korrekt ändring av när genvägar pausas, och React-nedmonteringsbuggen som föll ut ur en enda rad som var tänkt att göra editorn säkrare.
Här är den ärligaste meningen jag kan skriva om GeekBye v1.8.10: kraschen den är känd för att ha fixat hände aldrig någon. Ändringsloggen säger "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," vilket är sant och läser som en bugg som levererades, bet användarna och blev lappad i en uppföljning. Det var den inte. Kraschen introducerades av en commit och raderades av en annan i samma pull request, tolv minuter isär, samma fredagsmorgon. Den levde helt inuti en gren. Release-anteckningarna beskriver ett ärr som bildades och läkte innan någon utanför repot kunde se det.
Det är inte ett misslyckande för ändringsloggen; det är den ärliga formen på iterativt arbete, och det är värt att berätta eftersom de tolv minuterna innehåller en genuint lärorik React-bugg. Men för att förstå kraschen måste du förstå vad releasen egentligen var till för — eftersom kraschen var en följdskada av att fixa något annat.
Vad releasen egentligen handlade om
GeekBye låter dig binda om sina tangentbordsgenvägar i Inställningar. Medan du spelar in en ny kombination måste appen hindra sina globala genvägar från att utlösas — annars skulle det att trycka Cmd+B för att tilldela den bara växla fönstret istället för att fångas. Så editorn pausar globala genvägar över IPC: rendereraren anropar window.electronAPI.setShortcutsSuspended(true), och huvudprocessens ShortcutsHelper svarar med att anropa unregisterAll(), vilket släpper varje globalShortcut-registrering så att tangenttrycket når inspelningsinmatningen istället för att trigga en åtgärd. Vid återupptagning anropar den registerGlobalShortcuts() för att sätta tillbaka dem.
Buggen som startade allt det här (issue #233) handlade om omfattningen av den pausningen. Den gamla koden pausade genvägar hela den tid som genvägsinställningssidan var öppen — inte bara medan du aktivt spelade in en tangent. För det mesta är det osynligt. Men om du öppnade sidan, började göra något annat och navigerade bort utan att någonsin avsluta en redigering, kunde appen bli lämnad med alla sina globala genvägar oregistrerade — tyst döda — tills du gick tillbaka. Commiten som fixar det är uppriktig om symptomet: att pausa för brett riskerade "making the app appear broken if the user forgot they had the page open."
Fixen är en avsmalning på en rad: pausa bara när en rad faktiskt redigeras — nycklat på !!editingShortcut, hook-tillståndet som håller id:t för raden du spelar in — istället för hela besöket. Bra ändring. Men "pausa bara under redigering" väcker en omedelbar fråga: vad räknas som att avsluta en redigering? Att trycka på en tangent avslutar den. Att trycka på Escape avbryter den. Men vad händer om du bara… klickar bort, eller byter till en annan app helt, mitt i inspelningen? Om ingenting avbryter redigeringen blir du kvar med att spela in en genväg i ett fönster som inte är i fokus, och genvägarna förblir pausade. Så samma PR lade till en säkerhetsventil: avbryt redigering när fokus lämnar. Och det är raden som kraschade.
Tolv-minuters-buggen
Säkerhetsventilen var en useEffect i ShortcutsSettings.tsx som, medan en rad redigerades, lyssnade efter blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Två lyssnare. En på window — utlöses när du byter till en annan app. En fäst direkt, som en rå DOM-lyssnare, på inspelnings-<input>:en — tänkt att utlösas när du klickar bort till ett annat element. De ser överflödiga och ofarliga ut. Det är de inte.
Gå igenom sekvensen för "switching windows mid-edit," vilket är exakt ändringsloggens fras:
- Du spelar in en genväg;
<input>:en är i fokus. Du trycker Cmd+Tab till en annan app. - Fönstrets blur utlöses.
handleBlurkörcancelEditing(), som sättereditingShortcuttillbaka tillnull. - Att sätta det tillståndet avmonterar
<input>:en — raden lämnar redigeringsläget, så inmatningen tas bort från DOM. - Att ta bort ett fokuserat element från DOM skickar synkront en
blur-händelse på det elementet. Den råainputRef-lyssnaren fångar den och anroparcancelEditing()igen — den här gången mitt i Reacts commit-fas, medan komponentträdet rivs ner. - Att anropa en tillståndssättare på en fiber som är mitt i nedmontering snubblar på en av Reacts interna invarianter, och den kastar:
"Should have a queue". Inställningsvyn kraschar.
Buggen är inte fönsterlyssnaren och det är egentligen inte konceptet "avbryt vid blur." Det är att en av de två lyssnarna var en rå addEventListener på ett React-hanterat element. React vet inte om den lyssnaren, städar inte upp den vid avmontering och — avgörande — webbläsaren utlöser blur under just den DOM-borttagning som React utför, så hanteraren återinträder i din tillståndslogik i värsta möjliga ögonblick. Fix-commitens meddelande säger det mer precist än jag kan: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
Fixen
bf28a50, tolv minuter efter att kraschen föddes, gör två små saker.
Först, den tar bort den råa inmatningslyssnaren och behåller bara fönstrets, vilket är den enda som överhuvudtaget behövde vara en manuell DOM-lyssnare (det finns ingen React-onBlur för "the whole window lost focus"):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
För det andra flyttar den klicka-bort-fallet — "user clicked another element" — till Reacts egen onBlur-prop på inmatningen, och skjuter upp det ett tick:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Båda delarna spelar roll. Att använda Reacts onBlur-prop istället för en rå lyssnare betyder att React äger hanterarens livscykel och inte lämnar den hängande. Och setTimeout(…, 0) garanterar att även när denna blur utlöses av en avmontering, landar cancelEditing()-anropet på nästa tick — efter att den nuvarande renderingen och commiten har avslutats — så den kan aldrig uppdatera tillstånd på en komponent som fortfarande rivs ner. Om komponenten redan är borta då är det uppskjutna anropet en ofarlig no-op.
Det är hela fixen. Ta bort lyssnaren React inte kunde se; låt React schemalägga den som är kvar.
Varför detta är den bra sortens tråkigt
Det finns en frestelse att arkivera det här som "trivialt" — en setTimeout, en raderad rad — och gå vidare. Men formen på det är ett av de vanligaste sätten React-skrivbordsappar kraschar på, och det är värt att internalisera:
- Kraschen dyker bara upp vid en livscykelgräns (avmontering), utlöst av en händelse som ramverket inte schemalägger (en nativ blur under DOM-borttagning). Den syns inte i ett klickigenom-test; den behöver den specifika "fokus lämnar under redigering"-vägen.
- Grundorsaken är att blanda två ägarskapsmodeller — råa DOM-händelser och Reacts syntetiska, schemalagda händelser — på samma element. I det ögonblick din hanterare muterar tillstånd som kan avmontera det elementet blir den råa vägen återinträdande.
- Fixen är inte "lägg till en vaktflagga" (även om en
isMounted-ref hade maskerat det); det är att ta bort den råa lyssnaren helt, vilket är den mindre ändringen och den rätta. Subtraktion igen.
Och metalexen, den som hela den här serien fortsätter kretsa kring: läs diffen, inte release-anteckningen. "Fixed a crash while editing keyboard shortcuts" är korrekt, men det döljer att kraschen var självförvållad, samma morgon och aldrig levererad — och att den verkliga releasen är en tyst korrekthetsvinst om när man ska pausa en global genväg.
Vad mer som levererades
Två saker till åkte med i v1.8.10, båda värda en rad. Genvägssystemet fick också självläkning över sömn/uppvakning: macOS kan tyst tappa en apps globala genvägsregistreringar när maskinen sover, så en återupptagningshanterare verifierar och omregistrerar dem nu, vilket betyder att dina genvägar fortfarande fungerar efter att laptopen vaknar utan en omstart. Och på AI-sidan blev klienten smartare om hastighetsgränser — strömmande förfrågningar som träffar en 429 backar nu av exponentiellt och nollställer sin räknare när du slutar spela in, istället för att vänta ut en fast fördröjning. Tre tillförlitlighetsfixar till olika delsystem, varav en är kraschen som aldrig var en krasch.
För det föregående kapitlet i v1-historien, att skriva ut ett möte till PDF utan ett PDF-bibliotek (v1.8.9); och för hela bågen, anatomin i att skeppa mjukvara till perfektion.