
Geboren en gefixt in twaalf minuten
De changelog van GeekBye v1.8.10 zegt dat het een crash verhielp tijdens het bewerken van sneltoetsen. Dat klopt — maar de crash werd geïntroduceerd en verholpen in dezelfde pull request, twaalf minuten uit elkaar, en bereikte nooit een enkele gebruiker. Het echte verhaal is de betrouwbaarheidscascade die hem voortbracht: een kleine, correcte wijziging aan wanneer sneltoetsen worden opgeschort, en de React-teardown-bug die uit één regel viel die bedoeld was om de editor veiliger te maken.
Hier is de eerlijkste zin die ik over GeekBye v1.8.10 kan schrijven: de crash die het beroemd verhielp, is nooit bij iemand gebeurd. De changelog zegt "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," wat waar is, en leest als een bug die werd uitgebracht, gebruikers beet, en in een vervolg werd gepatcht. Dat was niet zo. De crash werd door de ene commit geïntroduceerd en door de andere verwijderd in dezelfde pull request, twaalf minuten uit elkaar, op dezelfde vrijdagochtend. Hij leefde volledig binnen een branch. De release notes beschrijven een litteken dat zich vormde en genas voordat iemand buiten de repo het kon zien.
Dat is geen falen van de changelog; het is de eerlijke vorm van iteratief werk, en het is het vertellen waard omdat die twaalf minuten een oprecht leerzame React-bug bevatten. Maar om de crash te begrijpen moet je begrijpen waar de release eigenlijk voor was — want de crash was bijkomende schade van het fixen van iets anders.
Waar de release eigenlijk over ging
Met GeekBye kun je zijn sneltoetsen opnieuw instellen in Instellingen. Terwijl je een nieuwe combinatie opneemt, moet de app voorkomen dat zijn globale sneltoetsen afvuren — anders zou het indrukken van Cmd+B om die toe te wijzen gewoon het venster togglen in plaats van te worden opgevangen. Dus de editor schort de globale sneltoetsen op via IPC: de renderer roept window.electronAPI.setShortcutsSuspended(true) aan, en de ShortcutsHelper van het main-proces reageert door unregisterAll() aan te roepen, waardoor elke globalShortcut-registratie wordt gedropt zodat de toetsaanslag de opname-input bereikt in plaats van een actie te triggeren. Bij hervatten roept het registerGlobalShortcuts() aan om ze terug te zetten.
De bug waar dit allemaal mee begon (issue #233) ging over de reikwijdte van die opschorting. De oude code schortte de sneltoetsen op gedurende de hele tijd dat de sneltoetsen-instellingenpagina open was — niet alleen terwijl je actief een toets opnam. Meestal is dat onzichtbaar. Maar als je de pagina opende, iets anders ging doen, en wegnavigeerde zonder ooit een bewerking af te ronden, kon de app achterblijven met al zijn globale sneltoetsen niet-geregistreerd — stilletjes dood — totdat je terugging. De commit die het verhelpt is openhartig over het symptoom: te breed opschorten riskeerde "making the app appear broken if the user forgot they had the page open."
De fix is een versmalling van één regel: schort alleen op wanneer een rij daadwerkelijk wordt bewerkt — gekoppeld aan !!editingShortcut, de hook-state die de id bevat van de rij die je opneemt — in plaats van gedurende het hele bezoek. Goede wijziging. Maar "alleen opschorten tijdens het bewerken" roept meteen een vraag op: wat telt als het afronden van een bewerking? Een toets indrukken rondt het af. Escape indrukken annuleert het. Maar wat als je gewoon… wegklikt, of midden in de opname volledig naar een andere app wisselt? Als niets de bewerking annuleert, blijf je een sneltoets opnemen in een venster dat niet de focus heeft, en blijven de sneltoetsen opgeschort. Dus dezelfde PR voegde een veiligheidsklep toe: annuleer het bewerken wanneer de focus vertrekt. En dat is de regel die crashte.
De twaalf-minutenbug
De veiligheidsklep was een useEffect in ShortcutsSettings.tsx die, terwijl een rij werd bewerkt, luisterde naar blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Twee listeners. Eén op het window — vuurt af wanneer je naar een andere app wisselt. Eén rechtstreeks aangehangen, als rauwe DOM-listener, aan de opname-<input> — bedoeld om af te vuren wanneer je wegklikt naar een ander element. Ze zien er overbodig en onschuldig uit. Dat zijn ze niet.
Loop de reeks door voor "switching windows mid-edit," precies de bewoording van de changelog:
- Je neemt een sneltoets op; de
<input>heeft de focus. Je doet Cmd+Tab naar een andere app. - De venster-blur vuurt af.
handleBlurdraaitcancelEditing(), dieeditingShortcutterugzet opnull. - Het instellen van die state unmount de
<input>— de rij verlaat de bewerkmodus, dus de input wordt uit de DOM verwijderd. - Een gefocust element uit de DOM verwijderen verzendt synchroon een
blur-event op dat element. De rauweinputRef-listener vangt het op en roeptcancelEditing()opnieuw aan — dit keer midden in React's commit-fase, terwijl de componentboom wordt afgebroken. - Een state-setter aanroepen op een fiber die midden in de teardown zit, activeert een van React's interne invarianten, en het gooit op:
"Should have a queue". De instellingenweergave crasht.
De bug is niet de venster-listener en ook niet echt het concept van "annuleren bij blur." Het is dat een van de twee listeners een rauwe addEventListener op een React-beheerd element was. React weet niets van die listener, ruimt hem niet op bij unmount, en — cruciaal — de browser vuurt blur af tijdens juist die DOM-verwijdering die React uitvoert, dus de handler gaat je state-logica opnieuw binnen op het slechtst mogelijke moment. De boodschap van de fix-commit zegt het preciezer dan ik kan: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
De fix
bf28a50, twaalf minuten nadat de crash werd geboren, doet twee kleine dingen.
Ten eerste verwijdert het de rauwe input-listener en houdt alleen die op het venster, de enige die überhaupt een handmatige DOM-listener moest zijn (er is geen React onBlur voor "het hele venster verloor de focus"):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Ten tweede verplaatst het het klik-weg-geval — "gebruiker klikte op een ander element" — naar React's eigen onBlur-prop op de input, en stelt het een tik uit:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Beide onderdelen zijn belangrijk. React's onBlur-prop gebruiken in plaats van een rauwe listener betekent dat React de levenscyclus van de handler bezit en hem niet laat bungelen. En de setTimeout(…, 0) garandeert dat zelfs wanneer deze blur wél door een unmount wordt getriggerd, de cancelEditing()-aanroep op de volgende tik landt — nadat de huidige render en commit klaar zijn — zodat hij nooit state kan bijwerken op een component die nog aan het afbreken is. Als de component tegen die tijd al weg is, is de uitgestelde aanroep een onschuldige no-op.
Dat is de hele fix. Verwijder de listener die React niet kon zien; laat React degene die overblijft inplannen.
Waarom dit het goede soort saai is
Er is een verleiding om dit onder "triviaal" te scharen — een setTimeout, een verwijderde regel — en verder te gaan. Maar de vorm ervan is een van de meest voorkomende manieren waarop React-desktopapps crashen, en het is de moeite waard om te internaliseren:
- De crash verschijnt alleen op een levenscyclusgrens (unmount), getriggerd door een event dat het framework niet inplant (een native blur tijdens DOM-verwijdering). Het duikt niet op in een doorklik-test; het heeft het specifieke pad "focus vertrekt tijdens het bewerken" nodig.
- De grondoorzaak is het mengen van twee eigendomsmodellen — rauwe DOM-events en React's synthetische, ingeplande events — op hetzelfde element. Op het moment dat je handler state muteert die dat element kan unmounten, wordt het rauwe pad re-entrant.
- De fix is niet "voeg een guard-vlag toe" (hoewel een
isMounted-ref het zou hebben gemaskeerd); het is de rauwe listener volledig verwijderen, wat de kleinere wijziging is en de juiste. Opnieuw aftrekken.
En de metales, degene waar deze hele serie steeds omheen draait: lees de diff, niet de release note. "Fixed a crash while editing keyboard shortcuts" is accuraat, maar het verbergt dat de crash zelf-toegebracht was, dezelfde ochtend, en nooit werd uitgebracht — en dat de echte release een stille correctheidswinst is over wanneer je een globale sneltoets moet opschorten.
Wat er nog meer werd uitgebracht
Nog twee dingen liftten mee in v1.8.10, beide een regel waard. Het sneltoetssysteem kreeg ook zelfherstel over slaap/ontwaken: macOS kan de globale sneltoets-registraties van een app stilletjes laten vallen wanneer de machine slaapt, dus een resume-handler verifieert en herregistreert ze nu, wat betekent dat je sneltoetsen nog werken nadat de laptop ontwaakt zonder een herstart. En aan de AI-kant werd de client slimmer met rate limits — streamingverzoeken die een 429 raken, backen nu exponentieel af en resetten hun teller wanneer je stopt met opnemen, in plaats van een vaste vertraging uit te zitten. Drie betrouwbaarheidsfixes aan verschillende subsystemen, waarvan er één de crash is die nooit een crash was.
Voor het vorige hoofdstuk in het v1-verhaal, een meeting naar PDF printen zonder PDF-bibliotheek (v1.8.9); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.