
Zrozen a opraven za dvanáct minut
Changelog GeekBye v1.8.10 říká, že opravil pád při úpravě klávesových zkratek. Opravdu ho opravil — ale ten pád byl zaveden a opraven ve stejném pull requestu, s odstupem dvanácti minut, a nikdy nedosáhl ani jednoho uživatele. Skutečný příběh je kaskáda spolehlivosti, která ho vyprodukovala: malá, správná změna toho, kdy se zkratky pozastavují, a bug s React demontáží, který vypadl z jednoho řádku, jenž měl udělat editor bezpečnějším.
Zde je nejpoctivější věta, kterou o GeekBye v1.8.10 mohu napsat: pád, kvůli jehož opravě je slavná, se nikdy nikomu nestal. Changelog říká "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," což je pravda a čte se jako bug, který se vydal, kousl uživatele a byl opraven v následné verzi. Nebyl. Pád byl zaveden jedním commitem a smazán jiným ve stejném pull requestu, s odstupem dvanácti minut, téhož pátečního rána. Žil zcela uvnitř jednoho branche. Poznámky k vydání popisují jizvu, která se vytvořila a zahojila, než ji mohl kdokoli mimo repo vidět.
To není selhání changelogu; je to poctivý tvar iterativní práce, a stojí za to to vyprávět, protože těch dvanáct minut obsahuje opravdu poučný React bug. Ale abys pádu porozuměl, musíš pochopit, k čemu vydání vlastně bylo — protože pád byl vedlejší škoda z opravy něčeho jiného.
K čemu vydání vlastně bylo
GeekBye ti umožňuje přemapovat si klávesové zkratky v Nastavení. Zatímco zaznamenáváš novou kombinaci, aplikace musí zabránit svým globálním zkratkám ve spuštění — jinak by stisk Cmd+B pro její přiřazení jen přepnul okno místo toho, aby byl zachycen. Takže editor pozastaví globální zkratky přes IPC: renderer volá window.electronAPI.setShortcutsSuspended(true) a ShortcutsHelper hlavního procesu odpoví voláním unregisterAll(), zahodíc každou registraci globalShortcut, aby stisk klávesy dosáhl zachytávacího inputu místo spuštění akce. Při obnovení volá registerGlobalShortcuts(), aby je vrátil zpět.
Bug, který to všechno spustil (issue #233), byl o rozsahu toho pozastavení. Starý kód pozastavoval zkratky po celou dobu, kdy byla stránka nastavení zkratek otevřená — ne jen zatímco jsi aktivně zaznamenával klávesu. Většinu času je to neviditelné. Ale pokud jsi otevřel stránku, začal dělat něco jiného a odnavigoval, aniž bys kdy dokončil úpravu, aplikace mohla zůstat se všemi svými globálními zkratkami neregistrovanými — tiše mrtvými — dokud ses nevrátil. Commit, který to opravuje, je upřímný ohledně symptomu: příliš široké pozastavení riskovalo "making the app appear broken if the user forgot they had the page open."
Oprava je jednořádkové zúžení: pozastav jen tehdy, když se řádek skutečně upravuje — klíčováno na !!editingShortcut, stav hooku držící id řádku, který zaznamenáváš — místo pro celou návštěvu. Dobrá změna. Ale „pozastav jen během úpravy" vyvolává okamžitou otázku: co se počítá jako dokončení úpravy? Stisk klávesy ji dokončí. Stisk Escape ji zruší. Ale co když prostě… klikneš jinam nebo přepneš úplně do jiné aplikace, uprostřed zachytávání? Pokud nic úpravu nezruší, zůstaneš zaznamenávat zkratku do okna, které není zaostřené, a zkratky zůstanou pozastavené. Tak stejný PR přidal pojistný ventil: zruš úpravu, když fokus odejde. A to je ten řádek, který spadl.
Bug za dvanáct minut
Pojistný ventil byl useEffect v ShortcutsSettings.tsx, který, zatímco se řádek upravoval, naslouchal na blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Dva listenery. Jeden na window — spustí se, když přepneš do jiné aplikace. Jeden připojený přímo, jako raw DOM listener, na zachytávací <input> — určený ke spuštění, když klikneš jinam na jiný prvek. Vypadají nadbytečně a neškodně. Nejsou.
Projdi si sekvenci pro "switching windows mid-edit," což je přesně fráze z changelogu:
- Zaznamenáváš zkratku;
<input>je zaostřený. Uděláš Cmd+Tab do jiné aplikace. - Blur okna se spustí.
handleBlurspustícancelEditing(), který nastavíeditingShortcutzpět nanull. - Nastavení toho stavu odmontuje
<input>— řádek opustí režim úpravy, takže input je odstraněn z DOM. - Odstranění zaostřeného prvku z DOM synchronně odešle událost
blurna tomto prvku. RawinputReflistener ji zachytí a zavolácancelEditing()znovu — tentokrát uprostřed commit fáze Reactu, zatímco se strom komponent bourá. - Volání setteru stavu na fiberu, který je uprostřed demontáže, klopýtne o jeden z interních invariantů Reactu, a vyhodí:
"Should have a queue". Pohled nastavení spadne.
Bug není listener okna a není to vlastně koncept „zrušení při bluru". Je to tím, že jeden ze dvou listenerů byl raw addEventListener na prvku spravovaném Reactem. React o tom listeneru neví, neuklidí ho při unmount a — což je klíčové — prohlížeč spustí blur během právě toho odstraňování z DOM, které React provádí, takže handler znovu vstoupí do tvé logiky stavu v nejhorší možný okamžik. Zpráva commitu s opravou to říká přesněji, než dokážu já: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
Oprava
bf28a50, dvanáct minut poté, co se pád zrodil, dělá dvě malé věci.
Za prvé, maže raw input listener a ponechává jen ten okenní, což je jediný, který vůbec musel být manuální DOM listener (neexistuje React onBlur pro „celé okno ztratilo fokus"):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Za druhé, přesouvá případ kliknutí-jinam — „uživatel klikl na jiný prvek" — na vlastní onBlur prop Reactu na inputu, a odloží ho o tick:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Obě části jsou důležité. Použití onBlur propu Reactu místo raw listeneru znamená, že React vlastní životní cyklus handleru a nenechá ho viset. A setTimeout(…, 0) zaručuje, že i když je tento blur vyvolán demontáží, volání cancelEditing() přistane na dalším ticku — poté, co aktuální render a commit skončily — takže nikdy nemůže aktualizovat stav na komponentu, který se pořád bourá. Pokud už je komponent do té doby pryč, odložené volání je neškodný no-op.
To je celá oprava. Smaž listener, který React neviděl; nech React naplánovat ten, který zbyl.
Proč je tohle ten dobrý druh nudy
Existuje pokušení zařadit tohle pod „triviální" — jeden setTimeout, jeden smazaný řádek — a jít dál. Ale jeho tvar je jedním z nejčastějších způsobů, jak React desktopové aplikace padají, a stojí za to si ho zvnitřnit:
- Pád se objeví jen na hranici životního cyklu (unmount), spuštěný událostí, kterou framework neplánuje (nativní blur během odstraňování z DOM). Neobjeví se v click-through testu; potřebuje konkrétní cestu „fokus odchází během úpravy".
- Kořenová příčina je míchání dvou modelů vlastnictví — raw DOM události a syntetické, naplánované události Reactu — na tomtéž prvku. Ve chvíli, kdy tvůj handler mutuje stav, který může odmontovat ten prvek, se raw cesta stává reentrantní.
- Oprava není „přidej ochrannou vlajku" (ačkoli
isMountedref by to zamaskoval); je to úplné odstranění raw listeneru, což je menší změna a ta správná. Zase odčítání.
A meta-lekce, ta, kolem které celá tahle série stále krouží: čti diff, ne poznámku k vydání. "Fixed a crash while editing keyboard shortcuts" je přesné, ale skrývá, že pád byl vlastnoručně způsobený, téhož rána, a nikdy se nevydal — a že skutečné vydání je tiché vítězství správnosti o tom, kdy pozastavit globální zkratku.
Co dalšího se vydalo
Ještě dvě věci se svezly ve v1.8.10, obě stojí za řádek. Systém zkratek také získal samoléčení přes sleep/wake: macOS může tiše zahodit registrace globálních zkratek aplikace, když stroj usne, takže handler obnovení je teď znovu ověřuje a znovu registruje, což znamená, že tvoje zkratky pořád fungují poté, co se laptop probudí, bez restartu. A na straně AI se klient zchytřel ohledně rate limitů — streaming požadavky, které narazí na 429, teď exponenciálně ustupují a resetují svůj čítač, když zastavíš nahrávání, místo aby vyčkávaly fixní zpoždění. Tři opravy spolehlivosti v různých subsystémech, jednou z nichž je pád, který nikdy nebyl pádem.
Předchozí kapitolu příběhu v1 najdete v tisk schůzky do PDF bez PDF knihovny (v1.8.9); a celý oblouk v anatomii dodávání softwaru k dokonalosti.