
Født og fixet på tolv minutter
GeekBye v1.8.10s ændringslog siger, at den fixede et nedbrud under redigering af tastaturgenveje. Det gjorde den — men nedbruddet blev introduceret og fixet i den samme pull request, tolv minutter fra hinanden, og nåede aldrig en eneste bruger. Den rigtige historie er den pålidelighedskaskade, der producerede det: en lille, korrekt ændring af, hvornår genveje sættes på pause, og React-nedrivningsbuggen, der faldt ud af én linje, der skulle gøre editoren sikrere.
Her er den mest ærlige sætning, jeg kan skrive om GeekBye v1.8.10: nedbruddet, den er berømt for at have fixet, skete aldrig for nogen. Ændringsloggen siger "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," hvilket er sandt og læser som en bug, der blev shippet, bed brugerne og blev lappet i en opfølgning. Det var den ikke. Nedbruddet blev introduceret af én commit og slettet af en anden i den samme pull request, tolv minutter fra hinanden, samme fredag morgen. Det levede fuldstændig inde i en gren. Release-noterne beskriver et ar, der dannede sig og helede, før nogen uden for repoet kunne se det.
Det er ikke en fiasko for ændringsloggen; det er den ærlige form på iterativt arbejde, og det er værd at fortælle, fordi de tolv minutter indeholder en genuint lærerig React-bug. Men for at forstå nedbruddet er du nødt til at forstå, hvad releasen egentlig var til — fordi nedbruddet var følgeskade af at fixe noget andet.
Hvad releasen egentlig handlede om
GeekBye lader dig binde sine tastaturgenveje om i Indstillinger. Mens du optager en ny kombination, er appen nødt til at stoppe sine globale genveje fra at udløses — ellers ville det at trykke Cmd+B for at tildele den bare skifte vinduet i stedet for at blive fanget. Så editoren sætter globale genveje på pause over IPC: rendereren kalder window.electronAPI.setShortcutsSuspended(true), og hovedprocessens ShortcutsHelper svarer ved at kalde unregisterAll(), der dropper hver globalShortcut-registrering, så tastetrykket når optagelsesinputtet i stedet for at trigge en handling. Ved genoptagelse kalder den registerGlobalShortcuts() for at sætte dem tilbage.
Buggen, der startede det hele (issue #233), handlede om omfanget af den pause. Den gamle kode satte genveje på pause i hele den tid, genvejsindstillingssiden var åben — ikke kun mens du aktivt optog en tast. Det meste af tiden er det usynligt. Men hvis du åbnede siden, begyndte at lave noget andet og navigerede væk uden nogensinde at afslutte en redigering, kunne appen blive efterladt med alle sine globale genveje uregistrerede — stille døde — indtil du gik tilbage. Commiten, der fixer det, er ærlig om symptomet: at sætte for bredt på pause risikerede "making the app appear broken if the user forgot they had the page open."
Fixet er en indsnævring på én linje: sæt kun på pause, når en række faktisk redigeres — nøglet på !!editingShortcut, hook-tilstanden, der holder id'et på den række, du optager — i stedet for hele besøget. God ændring. Men "sæt kun på pause under redigering" rejser et øjeblikkeligt spørgsmål: hvad tæller som at afslutte en redigering? At trykke på en tast afslutter den. At trykke på Escape annullerer den. Men hvad nu hvis du bare… klikker væk eller skifter til en helt anden app midt i optagelsen? Hvis intet annullerer redigeringen, bliver du efterladt med at optage en genvej i et vindue, der ikke er i fokus, og genvejene forbliver på pause. Så den samme PR tilføjede en sikkerhedsventil: annuller redigering, når fokus forlader. Og det er linjen, der nedbrød.
Tolv-minutters-buggen
Sikkerhedsventilen var en useEffect i ShortcutsSettings.tsx, der, mens en række blev redigeret, lyttede efter blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
To lyttere. Én på window — udløses, når du skifter til en anden app. Én fæstnet direkte, som en rå DOM-lytter, på optagelses-<input>en — tænkt at udløses, når du klikker væk til et andet element. De ser overflødige og harmløse ud. Det er de ikke.
Gå igennem sekvensen for "switching windows mid-edit," hvilket er præcis ændringsloggens frase:
- Du optager en genvej;
<input>en er i fokus. Du trykker Cmd+Tab til en anden app. - Vinduets blur udløses.
handleBlurkørercancelEditing(), der sættereditingShortcuttilbage tilnull. - At sætte den tilstand afmonterer
<input>en — rækken forlader redigeringstilstanden, så inputtet fjernes fra DOM. - At fjerne et fokuseret element fra DOM udsender synkront en
blur-hændelse på det element. Den råinputRef-lytter fanger den og kaldercancelEditing()igen — denne gang midt i Reacts commit-fase, mens komponenttræet rives ned. - At kalde en tilstandssætter på en fiber, der er midt i nedrivning, snubler over en af Reacts interne invarianter, og den kaster:
"Should have a queue". Indstillingsvisningen nedbryder.
Buggen er ikke vinduelytteren, og det er egentlig ikke konceptet "annuller ved blur." Det er, at en af de to lyttere var en rå addEventListener på et React-styret element. React ved ikke om den lytter, rydder den ikke op ved afmontering og — afgørende — browseren udløser blur under netop den DOM-fjernelse, som React udfører, så handleren gentråder i din tilstandslogik i det værst tænkelige øjeblik. Fix-commitens besked siger det mere præcist, end jeg kan: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
Fixet
bf28a50, tolv minutter efter at nedbruddet blev født, gør to små ting.
Først sletter det den rå input-lytter og beholder kun vinduets, som er den eneste, der overhovedet behøvede at være en manuel DOM-lytter (der er ingen React-onBlur for "the whole window lost focus"):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
For det andet flytter det klik-væk-tilfældet — "user clicked another element" — over på Reacts egen onBlur-prop på inputtet, og udskyder det et tik:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Begge dele betyder noget. At bruge Reacts onBlur-prop i stedet for en rå lytter betyder, at React ejer handlerens livscyklus og ikke efterlader den dinglende. Og setTimeout(…, 0) garanterer, at selv når denne blur udløses af en afmontering, lander cancelEditing()-kaldet på næste tik — efter at den nuværende rendering og commit er afsluttet — så den kan aldrig opdatere tilstand på en komponent, der stadig rives ned. Hvis komponenten allerede er væk til den tid, er det udskudte kald en harmløs no-op.
Det er hele fixet. Slet lytteren, React ikke kunne se; lad React planlægge den, der er tilbage.
Hvorfor dette er den gode slags kedeligt
Der er en fristelse til at arkivere det her som "trivielt" — en setTimeout, en slettet linje — og gå videre. Men formen på det er en af de mest almindelige måder, React-desktopapps nedbryder på, og det er værd at internalisere:
- Nedbruddet dukker kun op ved en livscyklusgrænse (afmontering), udløst af en hændelse, som frameworket ikke planlægger (en nativ blur under DOM-fjernelse). Det viser sig ikke i en klik-igennem-test; det kræver den specifikke "fokus forlader under redigering"-sti.
- Grundårsagen er at blande to ejerskabsmodeller — rå DOM-hændelser og Reacts syntetiske, planlagte hændelser — på det samme element. I det øjeblik din handler muterer tilstand, der kan afmontere det element, bliver den rå sti gentrædende.
- Fixet er ikke "tilføj et vagtflag" (selvom en
isMounted-ref ville have maskeret det); det er at fjerne den rå lytter helt, hvilket er den mindre ændring og den rigtige. Subtraktion igen.
Og metalektionen, den som hele denne serie bliver ved med at kredse om: læs diffen, ikke release-noten. "Fixed a crash while editing keyboard shortcuts" er præcist, men det skjuler, at nedbruddet var selvforskyldt, samme morgen og aldrig shippet — og at den rigtige release er en stille korrekthedssejr om, hvornår man skal sætte en global genvej på pause.
Hvad ellers blev shippet
To ting mere kørte med i v1.8.10, begge en linje værd. Genvejssystemet fik også selvhelbredelse hen over dvale/opvågning: macOS kan stille tabe en apps globale genvejsregistreringer, når maskinen sover, så en genoptagelseshandler verificerer og genregistrerer dem nu, hvilket betyder, at dine genveje stadig virker, efter at laptoppen vågner, uden en genstart. Og på AI-siden blev klienten smartere om ratebegrænsninger — streaming-forespørgsler, der rammer et 429, backer nu af eksponentielt og nulstiller deres tæller, når du stopper optagelsen, i stedet for at vente en fast forsinkelse ud. Tre pålidelighedsfixes til forskellige undersystemer, hvoraf det ene er nedbruddet, der aldrig var et nedbrud.
For det forrige kapitel i v1-historien, at printe et møde til PDF uden et PDF-bibliotek (v1.8.9); og for hele buen, anatomien i at shippe software til perfektion.