Corrigindo uma race, um bug da tecla Esc e um crash de chave duplicada no Greenhouse
Um lote de correções de robustez e acessibilidade para o Greenhouse, meu app desktop em Tauri para gerenciar projetos criativos. Nada aqui é glamoroso: é o tipo de passada de limpeza que todo app precisa depois que as primeiras features chegam e você começa a notar as arestas.
A race que ninguém pegaria só de ler o código uma vez
O Greenhouse tem uma função de “atualizar o dia” que busca de novo o estado no backend Rust depois de quase qualquer ação: capturar uma ideia, guardar um projeto no vault, registrar um touch. Ela se chama refreshDay, e é extremamente simples: chama o backend, pega o estado atualizado, atribui.
O problema apareceu quando olhei de perto o que acontece quando você guarda um projeto no vault a partir da sua tela de detalhe. Essa ação chama duas coisas em sequência: um callback onChanged e um callback onClose, e os dois chamam refreshDay. Então duas requisições pelo estado do dia saem quase ao mesmo tempo. Nada garante que elas voltem na ordem em que foram enviadas.
Se a primeira (disparada antes, carregando dados um pouco mais antigos) acabar resolvendo depois da segunda, sua resposta desatualizada sobrescreve a mais recente, e um projeto que você acabou de guardar no vault pode voltar a aparecer na tela. Guardá-lo de novo faria a decisão de vault ser carimbada duas vezes.
A correção é um padrão que eu já tinha usado antes, mas nunca tinha precisado aqui: um contador de sequência monotônico. Cada chamada a refreshDay incrementa um contador e guarda o próprio número. Quando a resposta volta, ela só é aplicada se o número dela ainda bater com o último número emitido. Respostas atrasadas e desatualizadas simplesmente são descartadas.
let refreshSeq = 0;
async function refreshDay() {
const seq = ++refreshSeq;
try {
const next = await getDailyState();
if (seq === refreshSeq) daily = next;
} catch {
// Keep showing the day we already have.
}
}
O que tornou isso satisfatório (e um pouco humilhante) foi escrever um teste de regressão para o caso e ver minha primeira versão passar mesmo com a correção removida temporariamente. O teste checava uma condição que já era verdadeira antes de qualquer uma das chamadas concorrentes ter resolvido, então na prática não testava nada. Só percebi isso porque criei o hábito de quebrar a correção de propósito e rodar o teste de novo: se um “teste de regressão” não consegue falhar, ele não é um teste de regressão.
Tecla Escape encontra botão desabilitado
Todo diálogo no Greenhouse desabilita o botão Cancelar enquanto um submit está em andamento, para você não conseguir disparar uma segunda requisição enquanto a primeira ainda está rodando. Razoável, só que elementos <dialog> nativos respondem à tecla Escape independentemente de qualquer botão, e eu não tinha levado isso em conta. Aperte Escape enquanto uma captura está salvando e o diálogo fecha mesmo assim, apesar do botão Cancelar desabilitado, e qualquer estado que deveria ser atualizado ao concluir (contagem de streak, worklist) simplesmente nunca acontece, porque o callback nunca dispara.
O elemento dialog na verdade dispara um evento cancel antes de fechar, e esse evento é cancelável:
<dialog
oncancel={(e) => {
if (busy) e.preventDefault();
}}
>
Simples depois que encontrei, mas fácil de passar despercebido até alguém (ou algum teste) apertar Escape no meio de um submit.
Uma chave duplicada que só morde no exato momento errado
O histórico de touches na tela de detalhe do projeto usava timestamp como chave, com resolução de um segundo. Registre um touch, depois avance o estágio do projeto imediatamente (o que também registra um touch) dentro do mesmo segundo, e você tem duas entradas na lista com a mesma chave. O Svelte não gosta disso: a tela de detalhe travava. A correção foi simplesmente usar o índice da lista como chave em vez do timestamp, que é o que deveria ter sido desde o início, já que essas são entradas ordenadas e somente de inserção (append-only).
Retalhos de acessibilidade
Alguns itens menores de a11y vieram de uma revisão anterior: o id do heading de uma zona era construído direto a partir do texto do título da zona, o que quebrava para “Ripe today” porque ids ARIA não podem conter espaços. Transformei em slug. A contagem de itens ao lado de cada heading de zona tinha aria-hidden, então usuários de leitor de tela nunca ouviam quantos itens havia numa zona: removi isso. E a troca para a visão de “sucesso” do diálogo de captura não movia o foco para lugar nenhum, então um usuário de leitor de tela ficava com o foco preso num campo de formulário que não existia mais. Agora ela foca o heading de confirmação, e refoca o campo de nome se você clicar em “Capturar outro”.
Verificando tudo, e esbarrando numa parede de um jeito divertido
O Greenhouse tem uma configuração baseada em WebDriver para dirigir o app de verdade já buildado (não um teste mockado): cliques reais, idas e voltas reais de IPC. Usei isso para confirmar de verdade as correções de foco e ARIA no navegador, o que foi satisfatório já que o jsdom só consegue aproximar isso.
Tentei usar a mesma configuração para enviar um Escape de verdade e verificar a correção do cancelamento do diálogo de ponta a ponta. Não funcionou, e em vez de assumir que meu código estava errado, escrevi um script de sondagem minúsculo que só enviava um caractere imprimível para um campo de texto focado e checava se ele aparecia. Não apareceu. Descobri que o endpoint /actions do plugin WebDriver ainda não entrega eventos de tecla de verdade para a webview na versão que estou usando. Um bom lembrete para separar “minha ferramenta está quebrada” de “meu código está quebrado” antes de sair atrás de uma correção que não é necessária: a lógica de guarda do Esc continua totalmente coberta por um teste unitário que dispara um evento cancel real do DOM e checa se preventDefault foi chamado, só ainda não está provada contra um keypress literal do sistema operacional.
Leitura relacionada
O card de revisão do vault que nunca rotacionava
"Keep" definia uma flag no cliente, e um comentário prometia que a escolha mudaria amanhã. Nunca mudou. A correção foi uma coluna anulável.
O bug que meus testes unitários jamais poderiam ter encontrado
Um quadro kanban renderizava uma prop que ficava obsoleta com dados novos, e todo teste mockado passava porque um mock não consegue expressar isso.
O callback que não conseguia dizer o que aconteceu
Corrigir um bug de atualização do dashboard expôs um segundo escondido no mesmo componente compartilhado, e um terceiro bug que no fim não existia.
Você também pode achar útil
RackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba maisProton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maiseSIM Airalo
eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.
Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.
Saiba mais