
Nascido e corrigido em doze minutos
O changelog da GeekBye v1.8.10 diz que corrigiu um crash ao editar os atalhos de teclado. Corrigiu — mas o crash foi introduzido e corrigido no mesmo pull request, com doze minutos de diferença, e nunca chegou a um único usuário. A história real é a cascata de confiabilidade que o produziu: uma mudança pequena e correta sobre quando os atalhos são suspensos, e o bug de teardown de React que caiu de uma linha pensada para tornar o editor mais seguro.
Esta é a frase mais honesta que consigo escrever sobre a GeekBye v1.8.10: o crash pela correção do qual ela é famosa nunca aconteceu com ninguém. O changelog diz "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit", o que é verdade, e se lê como um bug que foi publicado, mordeu os usuários, e foi corrigido num follow-up. Não foi. O crash foi introduzido por um commit e apagado por outro no mesmo pull request, com doze minutos de diferença, na mesma manhã de sexta-feira. Viveu inteiramente dentro de um branch. As notas de versão descrevem uma cicatriz que se formou e sarou antes de qualquer um fora do repo poder vê-la.
Isso não é uma falha do changelog; é o formato honesto do trabalho iterativo, e vale a pena contar porque esses doze minutos contêm um bug de React genuinamente instrutivo. Mas para entender o crash você precisa entender para que a release era de verdade — porque o crash foi dano colateral de corrigir outra coisa.
Do que a release realmente tratava
A GeekBye deixa você reatribuir seus atalhos de teclado nas Configurações. Enquanto você grava uma combinação nova, o app tem que impedir que seus atalhos globais disparem — caso contrário, apertar Cmd+B para atribuí-lo apenas alternaria a janela em vez de ser capturado. Então o editor suspende os atalhos globais via IPC: o renderizador chama window.electronAPI.setShortcutsSuspended(true), e o ShortcutsHelper do processo principal responde chamando unregisterAll(), soltando cada registro de globalShortcut para que a tecla chegue ao input de captura em vez de disparar uma ação. Ao retomar, chama registerGlobalShortcuts() para colocá-los de volta.
O bug que começou tudo isso (issue #233) era sobre o escopo dessa suspensão. O código antigo suspendia os atalhos durante todo o tempo em que a página de configurações de atalhos estava aberta — não só enquanto você gravava ativamente uma tecla. Na maior parte do tempo isso é invisível. Mas se você abrisse a página, começasse a fazer outra coisa, e navegasse para longe sem nunca terminar uma edição, o app podia ficar com todos os seus atalhos globais sem registro — mortos em silêncio — até você voltar. O commit que corrige isso é franco sobre o sintoma: suspender de forma ampla demais arriscava "making the app appear broken if the user forgot they had the page open."
A correção é um estreitamento de uma linha: suspender só quando uma linha está de fato sendo editada — chaveado em !!editingShortcut, o estado do hook que guarda o id da linha que você está gravando — em vez de durante toda a visita. Boa mudança. Mas «suspender só enquanto edita» levanta uma pergunta imediata: o que conta como terminar uma edição? Apertar uma tecla a termina. Apertar Escape a cancela. Mas e se você simplesmente… clicar fora, ou trocar para outro app inteiramente, no meio da captura? Se nada cancela a edição, você fica gravando um atalho numa janela que não está com o foco, e os atalhos continuam suspensos. Então o mesmo PR adicionou uma válvula de segurança: cancelar a edição quando o foco vai embora. E essa é a linha que crashou.
O bug de doze minutos
A válvula de segurança era um useEffect em ShortcutsSettings.tsx que, enquanto uma linha estava sendo editada, escutava o blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Dois listeners. Um na window — dispara quando você troca para outro app. Um anexado diretamente, como um listener do DOM cru, ao <input> de captura — pensado para disparar quando você clica fora para outro elemento. Parecem redundantes e inofensivos. Não são.
Percorra a sequência de «switching windows mid-edit», que é exatamente a frase do changelog:
- Você está gravando um atalho; o
<input>está com o foco. Você faz Cmd+Tab para outro app. - O blur da janela dispara.
handleBlurexecutacancelEditing(), que devolveeditingShortcutparanull. - Definir esse estado desmonta o
<input>— a linha sai do modo de edição, então o input é removido do DOM. - Remover do DOM um elemento com o foco despacha de forma síncrona um evento
blurnesse elemento. O listener cru deinputRefo captura e chamacancelEditing()de novo — desta vez no meio da fase de commit do React, enquanto a árvore de componentes está sendo desmontada. - Chamar um setter de estado sobre um fiber que está no meio do teardown aciona um dos invariantes internos do React, e ele lança:
"Should have a queue". A visão de configurações crasha.
O bug não é o listener da janela e não é bem o conceito de «cancelar no blur». É que um dos dois listeners era um addEventListener cru sobre um elemento gerenciado pelo React. O React não conhece esse listener, não o limpa no desmonte, e — crucialmente — o navegador dispara o blur durante a própria remoção do DOM que o React está realizando, então o handler reentra na sua lógica de estado no pior instante possível. A mensagem do commit da correção diz de forma mais precisa do que eu consigo: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
A correção
bf28a50, doze minutos depois de o crash nascer, faz duas coisinhas.
Primeiro, apaga o listener cru do input e mantém só o da janela, que é o único que precisava mesmo ser um listener manual do DOM (não existe um onBlur do React para «a janela inteira perdeu o foco»):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Segundo, move o caso do clique-fora — «o usuário clicou em outro elemento» — para a própria prop onBlur do React no input, e o adia um tick:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Ambas as partes importam. Usar a prop onBlur do React em vez de um listener cru significa que o React é dono do ciclo de vida do handler e não vai deixá-lo pendurado. E o setTimeout(…, 0) garante que, mesmo quando esse blur é disparado por um desmonte, a chamada a cancelEditing() aterrissa no próximo tick — depois de o render e o commit atuais terminarem — então nunca pode atualizar o estado de um componente que ainda está se desmontando. Se o componente já sumiu até lá, a chamada adiada é um no-op inofensivo.
É essa a correção inteira. Apague o listener que o React não conseguia ver; deixe o React agendar o que sobrou.
Por que este é o bom tipo de chato
Há uma tentação de arquivar isto como «trivial» — um setTimeout, uma linha apagada — e seguir em frente. Mas o formato disso é uma das maneiras mais comuns de apps de desktop em React crasharem, e vale a pena internalizar:
- O crash só aparece num limite de ciclo de vida (desmonte), disparado por um evento que o framework não agenda (um blur nativo durante a remoção do DOM). Não vai aparecer num teste de cliques; ele precisa do caminho específico «o foco sai enquanto se edita».
- A causa raiz é misturar dois modelos de propriedade — eventos do DOM crus e os eventos sintéticos e agendados do React — no mesmo elemento. No momento em que seu handler muta um estado que pode desmontar aquele elemento, o caminho cru se torna reentrante.
- A correção não é «adicionar uma flag de guarda» (embora um ref
isMounteda tivesse mascarado); é remover o listener cru por completo, que é a mudança menor e a correta. Subtração, de novo.
E a meta-lição, aquela que esta série inteira fica rondando: leia o diff, não a nota de versão. "Fixed a crash while editing keyboard shortcuts" é exato, mas esconde que o crash foi autoinfligido, da mesma manhã, e nunca publicado — e que a release real é uma vitória silenciosa de correção sobre quando suspender um atalho global.
O que mais foi publicado
Mais duas coisas pegaram carona na v1.8.10, ambas merecendo uma linha. O sistema de atalhos também ganhou autorreparo ao longo do ciclo de suspender/acordar: o macOS pode descartar em silêncio os registros de atalhos globais de um app quando a máquina suspende, então um handler de retomada agora os reverifica e reregistra, o que significa que seus atalhos ainda funcionam depois que o laptop acorda sem reiniciar. E do lado da IA, o cliente ficou mais esperto com os limites de taxa — as requisições em streaming que batem num 429 agora fazem back off exponencial e reiniciam seu contador quando você para de gravar, em vez de esperar um atraso fixo. Três correções de confiabilidade em subsistemas diferentes, uma das quais é o crash que nunca foi um crash.
Para o capítulo anterior da história v1, imprimir uma reunião em PDF sem uma biblioteca de PDF (v1.8.9); e para todo o arco, a anatomia de entregar software até a perfeição.