
Geboren und behoben in zwölf Minuten
Das Changelog von GeekBye v1.8.10 sagt, es habe einen Crash beim Bearbeiten von Tastaturkürzeln behoben. Hat es — aber der Crash wurde im selben Pull Request eingeführt und behoben, zwölf Minuten auseinander, und erreichte keinen einzigen Nutzer. Die eigentliche Geschichte ist die Zuverlässigkeitskaskade, die ihn hervorbrachte: eine kleine, korrekte Änderung daran, wann Kürzel suspendiert werden, und der React-Teardown-Bug, der aus einer einzigen Zeile fiel, die den Editor sicherer machen sollte.
Hier ist der ehrlichste Satz, den ich über GeekBye v1.8.10 schreiben kann: der Crash, für dessen Behebung sie berühmt ist, passierte niemandem. Das Changelog sagt "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit", was stimmt, und liest sich wie ein Bug, der ausgeliefert wurde, Nutzer biss, und in einem Folge-Patch behoben wurde. Wurde er nicht. Der Crash wurde von einem Commit eingeführt und von einem anderen im selben Pull Request gelöscht, zwölf Minuten auseinander, am selben Freitagmorgen. Er lebte vollständig innerhalb eines Branches. Die Release-Notes beschreiben eine Narbe, die sich bildete und heilte, bevor irgendjemand außerhalb des Repos sie sehen konnte.
Das ist kein Versagen des Changelogs; es ist die ehrliche Form iterativer Arbeit, und es ist erzählenswert, weil die zwölf Minuten einen wirklich lehrreichen React-Bug enthalten. Aber um den Crash zu verstehen, musst du verstehen, wofür das Release eigentlich da war — denn der Crash war Kollateralschaden beim Beheben von etwas anderem.
Worum es beim Release eigentlich ging
GeekBye lässt dich seine Tastaturkürzel in den Einstellungen neu belegen. Während du eine neue Kombination aufnimmst, muss die App verhindern, dass ihre globalen Kürzel feuern — sonst würde Cmd+B zu drücken, um es zuzuweisen, nur das Fenster umschalten, statt aufgezeichnet zu werden. Also suspendiert der Editor die globalen Kürzel über IPC: der Renderer ruft window.electronAPI.setShortcutsSuspended(true) auf, und der ShortcutsHelper des Hauptprozesses antwortet, indem er unregisterAll() aufruft und jede globalShortcut-Registrierung fallen lässt, damit der Tastendruck den Aufnahme-Input erreicht, statt eine Aktion auszulösen. Beim Fortsetzen ruft er registerGlobalShortcuts() auf, um sie zurückzusetzen.
Der Bug, der all das anstieß (issue #233), betraf den Geltungsbereich dieser Suspendierung. Der alte Code suspendierte die Kürzel die ganze Zeit, in der die Kürzel-Einstellungsseite offen war — nicht nur, während du aktiv eine Taste aufnahmst. Meistens ist das unsichtbar. Aber wenn du die Seite öffnetest, etwas anderes anfingst, und wegnavigiertest, ohne je eine Bearbeitung abzuschließen, konnte die App mit all ihren globalen Kürzeln nicht registriert zurückbleiben — still tot — bis du zurückgingst. Der Commit, der es behebt, ist offen über das Symptom: zu breit zu suspendieren riskierte "making the app appear broken if the user forgot they had the page open."
Der Fix ist eine einzeilige Verengung: nur suspendieren, wenn eine Zeile tatsächlich bearbeitet wird — verschlüsselt auf !!editingShortcut, dem Hook-State, der die id der Zeile hält, die du aufnimmst — statt für den ganzen Besuch. Gute Änderung. Aber „nur während der Bearbeitung suspendieren“ wirft eine sofortige Frage auf: was zählt als eine Bearbeitung zu beenden? Eine Taste zu drücken beendet sie. Escape zu drücken bricht sie ab. Aber was, wenn du einfach… wegklickst, oder mitten in der Aufnahme komplett zu einer anderen App wechselst? Wenn nichts die Bearbeitung abbricht, nimmst du ein Kürzel in ein Fenster auf, das nicht fokussiert ist, und die Kürzel bleiben suspendiert. Also fügte derselbe PR ein Sicherheitsventil hinzu: die Bearbeitung abbrechen, wenn der Fokus geht. Und das ist die Zeile, die abstürzte.
Der Zwölf-Minuten-Bug
Das Sicherheitsventil war ein useEffect in ShortcutsSettings.tsx, das, während eine Zeile bearbeitet wurde, auf Blur lauschte:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Zwei Listener. Einer am window — feuert, wenn du zu einer anderen App wechselst. Einer direkt, als roher DOM-Listener, an das Aufnahme-<input> gehängt — gedacht, um zu feuern, wenn du zu einem anderen Element wegklickst. Sie sehen redundant und harmlos aus. Sind sie nicht.
Geh die Sequenz für „switching windows mid-edit“ durch, was genau die Formulierung des Changelogs ist:
- Du nimmst ein Kürzel auf; das
<input>ist fokussiert. Du machst Cmd+Tab zu einer anderen App. - Der Fenster-Blur feuert.
handleBlurführtcancelEditing()aus, daseditingShortcutzurück aufnullsetzt. - Diesen State zu setzen unmountet das
<input>— die Zeile verlässt den Bearbeitungsmodus, also wird der Input aus dem DOM entfernt. - Ein fokussiertes Element aus dem DOM zu entfernen löst synchron ein
blur-Event auf diesem Element aus. Der roheinputRef-Listener fängt es und ruftcancelEditing()erneut auf — diesmal mitten in Reacts Commit-Phase, während der Komponentenbaum abgebaut wird. - Einen State-Setter auf einem Fiber mitten im Teardown aufzurufen stolpert über eines von Reacts internen Invarianten, und es wirft:
"Should have a queue". Die Einstellungsansicht stürzt ab.
Der Bug ist nicht der Fenster-Listener und nicht wirklich das Konzept „bei Blur abbrechen“. Es ist, dass einer der beiden Listener ein roher addEventListener auf einem von React verwalteten Element war. React weiß nichts von diesem Listener, räumt ihn beim Unmount nicht auf, und — entscheidend — der Browser feuert Blur genau während der DOM-Entfernung, die React durchführt, sodass der Handler zum schlimmstmöglichen Augenblick erneut in deine State-Logik eintritt. Die Nachricht des Fix-Commits sagt es präziser, als ich es kann: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
Der Fix
bf28a50, zwölf Minuten nachdem der Crash geboren wurde, tut zwei kleine Dinge.
Erstens löscht er den rohen Input-Listener und behält nur den des Fensters, der der einzige war, der überhaupt ein manueller DOM-Listener sein musste (es gibt kein React-onBlur für „das ganze Fenster hat den Fokus verloren“):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Zweitens verschiebt er den Klick-weg-Fall — „Nutzer klickte ein anderes Element an“ — auf Reacts eigene onBlur-Prop am Input, und schiebt ihn um einen Tick auf:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Beide Teile zählen. Reacts onBlur-Prop statt eines rohen Listeners zu nutzen heißt, React besitzt den Lebenszyklus des Handlers und lässt ihn nicht baumeln. Und das setTimeout(…, 0) garantiert, dass selbst wenn dieser Blur von einem Unmount ausgelöst wird, der cancelEditing()-Aufruf im nächsten Tick landet — nachdem der aktuelle Render und Commit fertig sind — sodass er nie State auf einer Komponente aktualisieren kann, die noch abgebaut wird. Ist die Komponente bis dahin schon weg, ist der aufgeschobene Aufruf ein harmloser No-op.
Das ist der ganze Fix. Lösche den Listener, den React nicht sehen konnte; lass React den verbleibenden einplanen.
Warum das die gute Sorte langweilig ist
Es gibt eine Versuchung, das unter „trivial“ abzulegen — ein setTimeout, eine gelöschte Zeile — und weiterzuziehen. Aber seine Form ist eine der häufigsten Arten, wie React-Desktop-Apps abstürzen, und es lohnt sich, sie zu verinnerlichen:
- Der Crash erscheint nur an einer Lebenszyklus-Grenze (Unmount), ausgelöst von einem Event, das das Framework nicht einplant (ein nativer Blur während der DOM-Entfernung). Er taucht in einem Klick-Test nicht auf; er braucht den spezifischen Pfad „der Fokus geht während der Bearbeitung“.
- Die Grundursache ist das Mischen zweier Besitzmodelle — rohe DOM-Events und Reacts synthetische, eingeplante Events — auf demselben Element. In dem Moment, in dem dein Handler State mutiert, der dieses Element unmounten kann, wird der rohe Pfad reentrant.
- Der Fix ist nicht „ein Guard-Flag hinzufügen“ (obwohl ein
isMounted-Ref es maskiert hätte); es ist das komplette Entfernen des rohen Listeners, was die kleinere Änderung ist und die richtige. Subtraktion, wieder.
Und die Meta-Lektion, die, um die diese ganze Serie kreist: lies den Diff, nicht die Release-Note. "Fixed a crash while editing keyboard shortcuts" ist zutreffend, aber es verbirgt, dass der Crash selbst zugefügt war, vom selben Morgen, und nie ausgeliefert wurde — und dass das eigentliche Release ein leiser Korrektheitsgewinn darüber ist, wann ein globales Kürzel zu suspendieren ist.
Was sonst noch ausgeliefert wurde
Zwei weitere Dinge fuhren in v1.8.10 mit, beide eine Zeile wert. Das Kürzelsystem gewann außerdem Selbstheilung über Ruhezustand/Aufwachen: macOS kann die globalen Kürzelregistrierungen einer App still fallen lassen, wenn die Maschine schläft, also verifiziert und registriert ein Resume-Handler sie jetzt neu, was heißt, deine Kürzel funktionieren nach dem Aufwachen des Laptops noch ohne Neustart. Und auf der KI-Seite wurde der Client klüger bei Rate Limits — Streaming-Anfragen, die auf ein 429 treffen, machen jetzt einen exponentiellen Back-off und setzen ihren Zähler zurück, wenn du die Aufnahme stoppst, statt eine feste Verzögerung abzuwarten. Drei Zuverlässigkeitsfixes an verschiedenen Subsystemen, einer davon der Crash, der nie ein Crash war.
Für das vorige Kapitel der v1-Geschichte, ein Meeting ohne PDF-Bibliothek als PDF drucken (v1.8.9); und für den ganzen Bogen, die Anatomie, Software bis zur Perfektion auszuliefern.