Steven
Steven8 min de leitura

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.

Engenharia
React
Desktop
Lançamentos da GeekBye
Nascido e corrigido em doze minutos

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:

  1. Você está gravando um atalho; o <input> está com o foco. Você faz Cmd+Tab para outro app.
  2. O blur da janela dispara. handleBlur executa cancelEditing(), que devolve editingShortcut para null.
  3. Definir esse estado desmonta o <input> — a linha sai do modo de edição, então o input é removido do DOM.
  4. Remover do DOM um elemento com o foco despacha de forma síncrona um evento blur nesse elemento. O listener cru de inputRef o captura e chama cancelEditing() de novo — desta vez no meio da fase de commit do React, enquanto a árvore de componentes está sendo desmontada.
  5. 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 isMounted a 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.

Artigos Relacionados

Imprimir uma reunião em PDF sem uma biblioteca de PDF
Steven
Steven10 min de leitura

Imprimir uma reunião em PDF sem uma biblioteca de PDF

A GeekBye exporta uma reunião como PDF, e não há nenhuma biblioteca de PDF em lugar nenhum do código. Ela renderiza HTML numa janela de navegador invisível e a imprime. Essa escolha é a história toda: tornou a funcionalidade fácil de construir e lhe deu todas as falhas que um navegador de verdade tem — um flash branco, um limite de comprimento de URL, e uma quebra de página que cortava as capturas ao meio. A correção do bug mais feio foi uma única linha de CSS.

Engenharia
Electron
Desktop
De OCR a pixels sem perder o fallback
Steven
Steven8 min de leitura

De OCR a pixels sem perder o fallback

A GeekBye parou de ler suas capturas com OCR e passou a enviar a imagem real para modelos de visão — que é a parte fácil. A parte instrutiva é a otimização que pulou o OCR por completo para os usuários com visão, quebrou sem alarde o fallback para modelos sem visão, e teve que ser desfeita até uma versão onde a rede de segurança roda em paralelo e não custa nada.

Engenharia
AI
Desktop
Os dois modos de falha de um overlay click-through
Steven
Steven10 min de leitura

Os dois modos de falha de um overlay click-through

A janela da GeekBye flutua por cima de tudo e deixa seus cliques passarem através dela — exceto onde tem botões. É um contrato de duas faces, e a v1.8.5 e a v1.8.14 são o que se vê quando cada face quebra: uma release onde o overlay engoliu um diálogo do sistema, outra onde roubou suas teclas digitadas. A correção vencedora para a segunda foi apagar código.

Engenharia
Electron
Desktop