Steven
Steven7 min lesing

Født og fikset på tolv minutter

GeekBye v1.8.10s endringslogg sier at den fikset et krasj under redigering av tastatursnarveier. Det gjorde den — men krasjen ble introdusert og fikset i den samme pull requesten, tolv minutter fra hverandre, og nådde aldri en eneste bruker. Den virkelige historien er pålitelighetskaskaden som produserte den: en liten, korrekt endring av når snarveier settes på pause, og React-nedrivingsbuggen som falt ut av én linje som skulle gjøre editoren tryggere.

Utvikling
React
Desktop
GeekBye-releaser
Født og fikset på tolv minutter

Her er den ærligste setningen jeg kan skrive om GeekBye v1.8.10: krasjen den er berømt for å ha fikset skjedde aldri for noen. Endringsloggen sier "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," som er sant og leses som en bug som ble shippet, bet brukerne og ble lappet i en oppfølging. Det var den ikke. Krasjen ble introdusert av én commit og slettet av en annen i den samme pull requesten, tolv minutter fra hverandre, samme fredag morgen. Den levde fullstendig inne i en gren. Release-notatene beskriver et arr som dannet seg og grodde før noen utenfor repoet kunne se det.

Det er ikke en fiasko for endringsloggen; det er den ærlige formen på iterativt arbeid, og det er verdt å fortelle, fordi de tolv minuttene inneholder en genuint lærerik React-bug. Men for å forstå krasjen må du forstå hva releasen egentlig var til — fordi krasjen var følgeskade av å fikse noe annet.

Hva releasen egentlig handlet om

GeekBye lar deg binde om sine tastatursnarveier i Innstillinger. Mens du tar opp en ny kombinasjon, må appen stoppe sine globale snarveier fra å utløses — ellers ville det å trykke Cmd+B for å tildele den bare veksle vinduet i stedet for å bli fanget. Så editoren setter globale snarveier på pause over IPC: rendereren kaller window.electronAPI.setShortcutsSuspended(true), og hovedprosessens ShortcutsHelper svarer med å kalle unregisterAll(), som dropper hver globalShortcut-registrering slik at tastetrykket når opptaksinputet i stedet for å utløse en handling. Ved gjenopptakelse kaller den registerGlobalShortcuts() for å sette dem tilbake.

Buggen som startet det hele (issue #233) handlet om omfanget av den pausen. Den gamle koden satte snarveier på pause hele den tiden snarveisinnstillingssiden var åpen — ikke bare mens du aktivt tok opp en tast. Mesteparten av tiden er det usynlig. Men hvis du åpnet siden, begynte å gjøre noe annet og navigerte vekk uten noen gang å fullføre en redigering, kunne appen bli stående med alle sine globale snarveier uregistrerte — stille døde — helt til du gikk tilbake. Commiten som fikser det er ærlig om symptomet: å sette for bredt på pause risikerte "making the app appear broken if the user forgot they had the page open."

Fiksen er en innsnevring på én linje: sett bare på pause når en rad faktisk redigeres — nøklet på !!editingShortcut, hook-tilstanden som holder id-en til raden du tar opp — i stedet for hele besøket. God endring. Men "sett bare på pause under redigering" reiser et umiddelbart spørsmål: hva teller som å fullføre en redigering? Å trykke på en tast fullfører den. Å trykke på Escape avbryter den. Men hva om du bare… klikker vekk, eller bytter til en helt annen app midt i opptaket? Hvis ingenting avbryter redigeringen, blir du stående med å ta opp en snarvei i et vindu som ikke er i fokus, og snarveiene forblir på pause. Så den samme PR-en la til en sikkerhetsventil: avbryt redigering når fokus forlater. Og det er linjen som krasjet.

Tolv-minutters-buggen

Sikkerhetsventilen var en useEffect i ShortcutsSettings.tsx som, mens en rad ble redigert, lyttet etter blur:

const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine

To lyttere. Én på window — utløses når du bytter til en annen app. Én festet direkte, som en rå DOM-lytter, på opptaks-<input>-en — ment å utløses når du klikker vekk til et annet element. De ser overflødige og harmløse ut. Det er de ikke.

Gå gjennom sekvensen for "switching windows mid-edit," som er nøyaktig endringsloggens frase:

  1. Du tar opp en snarvei; <input>-en er i fokus. Du trykker Cmd+Tab til en annen app.
  2. Vinduets blur utløses. handleBlur kjører cancelEditing(), som setter editingShortcut tilbake til null.
  3. Å sette den tilstanden avmonterer <input>-en — raden forlater redigeringsmodus, så inputet fjernes fra DOM.
  4. Å fjerne et fokusert element fra DOM sender synkront en blur-hendelse på det elementet. Den inputRef-lytteren fanger den og kaller cancelEditing() igjen — denne gangen midt i Reacts commit-fase, mens komponenttreet rives ned.
  5. Å kalle en tilstandssetter på en fiber som er midt i nedriving snubler over en av Reacts interne invarianter, og den kaster: "Should have a queue". Innstillingsvisningen krasjer.

Buggen er ikke vinduslytteren, og det er egentlig ikke konseptet "avbryt ved blur." Det er at en av de to lytterne var en addEventListener på et React-styrt element. React vet ikke om den lytteren, rydder den ikke opp ved avmontering og — avgjørende — nettleseren utløser blur under nettopp den DOM-fjerningen som React utfører, så handleren går inn igjen i tilstandslogikken din i det verst tenkelige øyeblikket. Fiks-commitens melding sier det mer presist enn jeg kan: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

Fiksen

bf28a50, tolv minutter etter at krasjen ble født, gjør to små ting.

Først sletter den den rå input-lytteren og beholder bare vinduets, som er den eneste som i det hele tatt trengte å være en manuell DOM-lytter (det finnes ingen React-onBlur for "the whole window lost focus"):

const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
  window.removeEventListener('blur', handleWindowBlur)
}

For det andre flytter den klikk-vekk-tilfellet — "user clicked another element" — over på Reacts egen onBlur-prop på inputet, og utsetter det ett tikk:

onBlur={() => {
  // Deferred to avoid state updates during React's render cycle.
  setTimeout(() => cancelEditing(), 0)
}}

Begge deler betyr noe. Å bruke Reacts onBlur-prop i stedet for en rå lytter betyr at React eier handlerens livssyklus og ikke lar den henge igjen. Og setTimeout(…, 0) garanterer at selv når denne blur utløses av en avmontering, lander cancelEditing()-kallet på neste tikk — etter at den nåværende renderingen og commiten er ferdig — så den kan aldri oppdatere tilstand på en komponent som fortsatt rives ned. Hvis komponenten allerede er borte da, er det utsatte kallet en harmløs no-op.

Det er hele fiksen. Slett lytteren React ikke kunne se; la React planlegge den som er igjen.

Hvorfor dette er den gode sorten kjedelig

Det er en fristelse til å arkivere dette som "trivielt" — en setTimeout, en slettet linje — og gå videre. Men formen på det er en av de vanligste måtene React-skrivebordsapper krasjer på, og det er verdt å internalisere:

  • Krasjen dukker bare opp ved en livssyklusgrense (avmontering), utløst av en hendelse som rammeverket ikke planlegger (en nativ blur under DOM-fjerning). Den viser seg ikke i en klikk-gjennom-test; den trenger den spesifikke "fokus forlater under redigering"-stien.
  • Grunnårsaken er å blande to eierskapsmodeller — rå DOM-hendelser og Reacts syntetiske, planlagte hendelser — på det samme elementet. I det øyeblikket handleren din muterer tilstand som kan avmontere det elementet, blir den rå stien reentrant.
  • Fiksen er ikke "legg til et vaktflagg" (selv om en isMounted-ref ville ha maskert det); det er å fjerne den rå lytteren helt, som er den mindre endringen og den riktige. Subtraksjon igjen.

Og metaleksen, den som hele denne serien fortsetter å kretse rundt: les diffen, ikke release-notatet. "Fixed a crash while editing keyboard shortcuts" er nøyaktig, men det skjuler at krasjen var selvpåført, samme morgen og aldri shippet — og at den virkelige releasen er en stille korrekthetsseier om når man skal sette en global snarvei på pause.

Hva annet som ble shippet

To ting til var med i v1.8.10, begge verdt en linje. Snarveisystemet fikk også selvhelbredelse over dvale/oppvåkning: macOS kan stille miste en apps globale snarveisregistreringer når maskinen sover, så en gjenopptakelseshandler verifiserer og registrerer dem på nytt nå, som betyr at snarveiene dine fortsatt virker etter at laptopen våkner uten en omstart. Og på AI-siden ble klienten smartere om ratebegrensninger — strømmeforespørsler som treffer en 429 backer nå av eksponentielt og nullstiller telleren sin når du stopper opptaket, i stedet for å vente ut en fast forsinkelse. Tre pålitelighetsfikser til forskjellige undersystemer, hvorav én er krasjen som aldri var et krasj.

For det forrige kapittelet i v1-historien, å skrive ut et møte til PDF uten et PDF-bibliotek (v1.8.9); og for hele buen, anatomien i å shippe programvare til perfeksjon.