1 pontos por GN⁺ 2024-11-19 | 1 comentários | Compartilhar no WhatsApp
  • O botão More do site da BBC UK falhava ao processar cliques apenas em certos ambientes de trabalho em casa, e o que parecia um bug comum de UI era na verdade um problema de sistema de coordenadas em múltiplos monitores
  • Quando um monitor externo ficava acima ou à esquerda do monitor principal, Chrome e Firefox podiam gerar valores negativos para screenX e screenY em eventos de click
  • O código existente identificava cliques de ponteiro com event.screenX > 0 || event.screenY > 0, então cliques com coordenadas negativas não eram reconhecidos como cliques de mouse
  • A correção foi simples: em vez de verificar se screenX e screenY eram maiores que 0, passou a verificar se eram diferentes de 0, ficando no formato event.type === 'click' && (event.screenX!== 0 || event.screenY!== 0)
  • Mesmo após testes unitários, Puppeteer, testes manuais e testes com tecnologias assistivas, esse tipo de bug pode permanecer por causa da ambiguidade da especificação UI Events e de suposições sobre coordenadas em múltiplos monitores

Bug de navegação da BBC reproduzido só em um ambiente específico

  • A barra de navegação do site da BBC UK abre um menu quando o usuário ativa o botão More
  • Esse botão usa o evento click, que pode ser disparado não só pelo mouse, mas também por toque e pelas teclas Enter e Space
  • Um integrante da equipe só enfrentava o problema ao usar o notebook de trabalho em casa; com o mesmo notebook no escritório, tudo funcionava normalmente
  • Mesmo em casa, a falha só acontecia quando a janela do navegador estava no monitor externo; na tela do notebook, o botão funcionava normalmente
  • Quando o problema ocorria, o handler em JavaScript não abria o menu e o comportamento que entrava em ação era o fallback sem JavaScript
  • No Safari, o mesmo problema não aparecia

A condição de reprodução era a posição do monitor

  • A equipe foi reduzindo as hipóteses para descobrir qual elemento do ambiente doméstico provocava o problema
  • O monitor externo estava posicionado acima da tela do notebook, e ao mudar essa disposição nas configurações do sistema operacional o problema parava de acontecer
  • Outro integrante da equipe também conseguiu reproduzir o bug ao ajustar a disposição dos monitores no sistema operacional da mesma forma
  • No início da investigação, duas condições já tinham ficado claras
    • No Safari, o problema não acontecia
    • O problema ocorria quando o monitor externo estava acima e à esquerda do monitor principal

Coordenadas negativas em screenX e screenY

  • Ao inspecionar com console.log o evento click do botão More, os valores de screenX e screenY apareciam como negativos no Chrome e no Firefox
  • O evento click é um tipo de PointerEvent independentemente da forma de entrada que o gerou, então o objeto do evento inclui informações sobre o mouse ou o ponteiro de toque que originou o clique
  • screenX e screenY representam, em pixels, as coordenadas do ponto clicado na tela
  • A DOM UI Events spec não parecia trazer informação específica sobre a possibilidade de esses atributos serem negativos
  • A diferença entre Safari e Chrome/Firefox mostra que a forma de representar coordenadas de tela pode variar entre navegadores em configurações com múltiplos monitores
  • Esse problema de interoperabilidade foi reportado à equipe do WebKit

Diferenças entre navegadores na forma de coordenadas em múltiplos monitores

  • Em uma configuração com múltiplos monitores, o sistema de coordenadas de tela do navegador trata vários monitores como se fossem uma única tela grande
  • Com dois monitores de 800px lado a lado, por exemplo, o intervalo do eixo x pode ir de 0 a 1600
  • No Safari, o intervalo de coordenadas aparentemente sempre começa no monitor mais à esquerda e mais acima, ficando em um intervalo positivo
  • No Chrome e no Firefox, as coordenadas parecem ser calculadas com base no monitor principal, então telas que ficam acima ou à esquerda dele podem gerar coordenadas negativas
  • Neste bug, o problema só acontecia quando screenX e screenY eram negativos

O código real do problema e a correção

  • No código problemático, isInvokedByMouse tentava verificar se o evento click tinha sido disparado por mouse ou por ponteiro de toque checando se screenX e screenY eram positivos
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;
const isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);

// ...

const toggleMenu = event => {
  // ...

  if (isInvokedByMouse(event) || isInvokedByKeyboard(event)) {
    event.preventDefault();

    // Do stuff to open the menu and move the focus...
  }
};
  • Esse código partia da suposição de que screenX e screenY seriam positivos em eventos de click disparados por ponteiro
  • Quando o usuário clicava no botão More em um monitor com coordenadas negativas de tela, o handler não reconhecia aquilo como clique e deixava a execução cair no comportamento padrão do link More
  • A correção foi trocar a checagem de maior que 0 por uma verificação de que os valores eram diferentes de 0
const isInvokedByMouse = event =>
  event.type === 'click' && (event.screenX !== 0 || event.screenY !== 0);
  • Com essa mudança, usuários com disposições incomuns de múltiplos monitores também puderam usar a barra de navegação do site da BBC

Problemas de design restantes e refatoração posterior

  • Embora a correção em si tenha sido simples, ainda restavam partes estranhas no código
  • Não havia necessidade de verificar se o click tinha vindo do mouse ou do teclado, e o handler tratava até eventos keydown, o que aumentava a complexidade
  • É preciso ter cuidado com as suposições feitas sobre o comportamento de uma API, e a falta de clareza da especificação sobre a possibilidade de valores negativos em screenX e screenY também ajudou a esconder o problema
  • O código havia passado por testes unitários, testes com Puppeteer e testes manuais em vários navegadores, dispositivos e ferramentas de tecnologia assistiva, mas o bug não foi detectado
  • Em uma atualização de 19 de novembro de 2024, foi informado que o componente de navegação acabou sendo refatorado depois, e o handler de eventos do botão menu também mudou bastante
  • O texto de acompanhamento explica a refatoração e responde a perguntas frequentes: How I refactored the BBC navigation bar and a follow-up FAQ

1 comentários

 
GN⁺ 2024-11-19
Comentários do Hacker News
  • Para complementar para quem não clicou até o relatório de bug do WebKit: um desenvolvedor do WebKit perguntou à BBC por que seria útil conseguir detectar se o evento veio do teclado, e o autor respondeu que a interoperabilidade era necessária por causa de casos de uso relacionados a acessibilidade.
    O botão de menu da barra de navegação do site britânico da BBC se comporta de forma ligeiramente diferente quando é aberto com um ponteiro e quando é aberto pelo teclado. O evento de clique sempre abre o menu, mas, quando aberto pelo ponteiro, o foco vai para o contêiner do menu; quando aberto pelo teclado, o foco vai para o primeiro link do menu sem a animação de abertura. O evento click é independente do dispositivo, o que é bom para criar uma experiência de usuário com teclado, e no teclado ele só é invocado por Space ou Enter. Se usar keydown, é preciso verificar manualmente se foi Space/Enter.
    Fonte: https://bugs.webkit.org/show_bug.cgi?id=281430

    • O interessante é que, se você interpretar ingenuamente em inglês a explicação do código e do bug do WebKit, ela não bate com a estrutura real do código. O código relevante é const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0; e const isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);; superficialmente, parece que ele tenta classificar o evento como vindo ou do mouse ou do teclado.
      Na prática, surgem quatro categorias: é mouse e não é teclado, é teclado e não é mouse, é ambos, não é nenhum dos dois. Como no bug original, “não é nenhum dos dois” é tratado de forma inadequada, e também fico em dúvida se “é ambos” funciona corretamente. O código deveria lidar deliberadamente com o fato de “ser teclado” e “ser mouse” serem booleanos separados, ou ser estruturado para que eventSource retorne categorias mutuamente exclusivas, como "keyboard", "mouse" e "not sure".
    • Isso não me parece um bug. O primeiro erro do desenvolvedor foi tentar criar experiências de usuário diferentes para teclado e mouse.
      É melhor seguir o comportamento padrão e projetar um componente que funcione nos dois casos de uso. Em acessibilidade, não se deve tentar ser esperto demais. No fim, virou uma solução quase um hack, e esse tipo de abordagem inevitavelmente quebra ou causa efeitos colaterais. O motivo de haver poucos bons ganchos para tratar as coisas de forma diferente no contexto de acessibilidade é que, para começo de conversa, essa não é uma área pensada para ser tratada de forma diferente.
    • Este texto me parece confuso. Entendo que a BBC quer um comportamento um pouco diferente dependendo de ser um “clique” do mouse ou um “clique” do teclado, e que, no teclado, quer dar foco ao primeiro link do menu sem animação.
      Ao mesmo tempo, também quer a conveniência de se vincular a apenas um evento. click permite isso, mas não há como saber se o evento foi gerado por um clique do mouse ou por entrada de teclado; então, no Chrome, se a posição do mouse é screenX=0, screenY=0, eles usam uma heurística instável que considera isso um clique na origem ou um acionamento pelo teclado. Como alguém que já trabalhou em projetos de acessibilidade, acho uma ideia bem ruim e, se eu visse isso em um PR, pediria para reescrever. Seria ideal que os navegadores tivessem o mesmo comportamento, mas o problema real parece ser que, em um click gerado pelo teclado, screenX e screenY têm pouquíssimo significado.
      Idealmente, não se deveria emitir um MouseEvent; deveria haver um evento mais genérico, aplicável tanto a teclado quanto a mouse, por exemplo algo como "trigger", que fornecesse a informação da origem do acionamento. Como isso não existe na especificação atual e é preciso uma solução agora, vincular também a keydown e, quando ocorrer click junto com keydown no mesmo elemento, tratar como entrada de teclado seria muito mais estável e menos hacky.
    • Entendo por que o autor precisa de screenX e screenY, mas ainda me pergunto por que screenX precisa retornar coordenadas reais da tela, em vez de uma posição interna do renderizador ou da página renderizada, como layerX, layerY.
      A necessidade do autor também poderia ser atendida com a posição no renderizador, sem vazar a posição da janela do navegador para todos os sites visitados.
    • Em “não queremos que o comportamento de foco e animação varie ligeiramente dependendo de o usuário ter ‘clicado’ para abrir o menu com um ponteiro ou com o teclado”, acho que don’t é um erro de digitação que deixa o sentido oposto ao pretendido.
  • No trecho “bastava mudar isInvokedByMouse, que verificava se screenX e screenY eram maiores que 0, para verificar se eram diferentes de 0”, fico curioso sobre o que aconteceria se, embora extremamente raro, o usuário realmente clicasse com o mouse na posição 0,0.
    Não sou familiarizado com JS; verificar != 0 é mesmo a melhor ou a única forma? Relendo, a frase dizendo que o manipulador de eventos também lida com keydown, então é complicado e precisará de mais refatoração depois, mas que por enquanto essa correção basta, parece tratar um pouco desse ponto.

    • Consultar a posição na tela parece uma heurística criada para inferir a natureza do evento. Intuitivamente, eu usaria instanceof MouseEvent, mas isso também parece arriscado ou hacky.
      Fico me perguntando por que dependem dessa heurística. Talvez seja porque toggleMenu é usado por vários manipuladores de eventos, ou talvez haja outras circunstâncias específicas da base de código. Sem ver o quadro completo, é difícil julgar. A resposta parece estar aqui: https://news.ycombinator.com/item?id=42174177
    • No código corrigido, já se verifica event.name == 'click'. Então não entendo por que tentariam filtrar alguns eventos de clique legítimos.
    • Não é bem assim. Dá para fazer uma seleção por media query com base em se o dispositivo de entrada principal é um dispositivo apontador e, além disso, se é um dispositivo de alta precisão, e filtrar com base nisso.
      Já usei isso antes para escolher qual layout mostrar. Se você quiser ouvir apenas entrada por toque, pode fazer isso e depois chamar preventDefault no evento para impedir que o navegador crie em seguida um evento click. Ou então simplesmente poupar o trabalho e escrever um manipulador de clique.
  • É digno de reconhecimento que a BBC, ao investir em acessibilidade, tenha encontrado um bug desagradável. Mas por que a indústria ainda não consegue fazer direito um dropdown que abra de forma consistente para todos os usuários?
    Acessibilidade é tão difícil assim? A BBC deveria ter usado um framework web ou web component que já lidasse com esse tipo de coisa? Como desenvolvedor full-stack mais focado em backend, mexer em componentes de navegador me deixa cauteloso. Há muitas sutilezas no comportamento, e as implementações foram testadas por muito tempo. Por exemplo, criar uma caixa de texto customizada sem investigar a fundo o comportamento das caixas de texto em cada plataforma parece uma receita para dar errado. Mesmo em sites de grandes empresas, vejo com frequência copiar/colar quebrado e caracteres desaparecendo. Não entendo por que caixas de texto quebram em 2024, e hoje o React me parece arrogante
    Pessoalmente, eu teria tentado resolver com templates no lado do servidor, um framework CSS como Bulma e o mínimo de JS. Não é adequado para sites que exigem branding customizado superpolido, mas as caixas de texto funcionam bem e o custo de desenvolvimento não fica excessivo. Não tenho certeza se isso atenderia aos padrões de acessibilidade da BBC

    • Não sei a resposta para todas as perguntas, mas para “acessibilidade é tão difícil assim?” posso responder com certeza: sim
      Um exemplo real são os modais. Se você não tem deficiência visual, vê uma caixa branca com componentes de UI dentro, sobre uma área cinza de “não mexa”. Ao usar um leitor de tela, não há garantia de que você receba essa informação. Quando você navega pelos elementos da UI com Tab e volta ao topo da caixa, será que determinado leitor de tela informa isso? Ele lista os elementos interativos disponíveis? Lista na mesma ordem que outros leitores de tela? E no celular, no Mac? O leitor de tela e o navegador reportam corretamente os elementos de entrada, ou permitem silenciosamente que o usuário escape para fora do modal e volte ao restante do site?
      Em acessibilidade, não dá para confiar que sistema operacional, navegador e leitor de tela vão cooperar ou agir de forma razoável nas situações apropriadas. Em 2019, tive de reportar um bug no VoiceOver + Safari em que uma margin CSS negativa fazia o leitor de tela ler um bloco de texto RTL com a ordem invertida. Visualmente aparecia como 9/10/2019, mas no leitor de tela soava como “ten slash nine slash two-thousand-and-nineteen”; como gambiarra, tive de marcar o texto como aria-hidden e inserir uma tag p invisível com a ordem correta. Então, quando você vê código estranho relacionado a acessibilidade, às vezes realmente não há uma maneira melhor. Mesmo que você vire a base de código inteira de cabeça para baixo e coloque acessibilidade como prioridade máxima, ela pode quebrar de forma difícil de entender no momento em que o JAWS ou o VoiceOver forem atualizados
    • Concordo. Dito isso, muitos problemas acabam surgindo quando os user agents customizam esses elementos de maneiras bastante suspeitas
      Em geral é aceitável, mas arquivos reset.css existem por um motivo, e aqui parece possível que tenham usado uma abordagem mais extrema para contornar completamente esse tipo de problema. Estou tentando inferir as decisões deles
  • Isso parece um bug autoinfligido, resultado de uma heurística errada. Assumiram que valores positivos de screenX/Y indicavam um evento de mouse, e a falta de rastreamento/logging tornou a investigação ainda mais complicada
    Em vez de verificar pointerType, uma propriedade mais adequada sugerida por outros comentários, fico um pouco surpreso que a solução do autor tenha sido acrescentar ainda mais heurísticas instáveis. É como concluir, a partir das duas pistas finais, que ao verificar as coordenadas screenX e screenY era preciso checar não só valores positivos, mas também negativos

    • Na verdade, é isso que vamos fazer. Em breve vamos mesclar o código para usar pointerId === -1 e depois fazer fallback para screenX === 0
      Cerca de 4 anos atrás, quando esse código foi escrito pela primeira vez, nem todos os navegadores usavam PointerEvent em click
  • Para começo de conversa, não entendo por que um site consegue obter a posição do mouse no sistema de coordenadas da tela

    • Procurei o motivo, mas não encontrei muita coisa. O fato de um site poder saber a posição da janela do navegador via window.screenX/window.screenY e de a posição do clique também poder ser reportada nesse sistema de coordenadas soa sem sentido no desktop
      O TOR Browser parece mascarar screenX e screenY para evitar fingerprinting. Fico curioso se alguém já viu um bom caso de uso para esse recurso. Só me vêm à mente uma aplicação com duas janelas interagindo entre si, ou um site cujo comportamento muda dependendo da posição na tela virtual
    • É útil ao criar um jogo composto por várias janelas pequenas do navegador que interagem entre si
      Ex.: https://youtu.be/3al8prbfK5o?si=loNtyqIfMFkppm5V
    • Porque foi fácil de implementar durante os 10 dias alocados para desenvolver JavaScript em 1995, e desde então a compatibilidade retroativa entrou em ação :(
    • Se você está reagindo a um evento de clique, pode querer saber as coordenadas do ponto clicado. Isso é usado principalmente em operações de clicar e arrastar, para calcular o delta entre eventos e atualizar a posição do objeto arrastado
      Não entendo por que verificar as coordenadas em vez de event.type. Ainda assim, o texto em si é um bom quebra-cabeça, e me identifico com a situação de olhar para código que não escrevi e perguntar “por que é importante que as coordenadas do clique não sejam 0?”, “não bastaria verificar se event.target é o botão que se quer ativar?”, “por que usar JavaScript se dá para fazer a mesma coisa com as tags details/summary?”
    • É usado em CAPTCHA sem JavaScript. Funciona bem e, ao clicar, envia apenas o x e o y do clique do mouse
  • Para começo de conversa, por que filtrar por coordenadas de tela? O que acontece se o usuário usa um dispositivo de entrada alternativo sem tela?
    O evento click por si só já é um sinal suficiente de que o usuário tentou ativar o menu. Não entendo por que reinventar a roda

    • Segundo o texto, isInvokedByMouse verificava se as coordenadas screenX ou screenY eram positivas para determinar se o evento click tinha sido chamado por um mouse ou ponteiro de toque, e não pelo teclado
      Eles tentaram detectar se a ativação era por teclado ou por mouse, e o autor presumiu que as coordenadas de tela de um evento de mouse seriam sempre positivas
  • Publiquei mais um post no blog para explicar o contexto que as pessoas queriam entender e responder às perguntas. Expliquei por que eu verificava screenX === 0 desde o começo, por que queria comportamentos diferentes dependendo da entrada por teclado ou mouse, e como refatorei para evitar novos incidentes
    Espero que ajude: https://www.joshtumath.uk/posts/2024-11-18-how-i-refactored-...

  • Qual seria a forma correta de verificar se foi um clique do mouse ou do teclado? Acho que eu ficaria tentado a definir uma flag em nível de módulo com base no evento mais recente: se mousedown foi mais recente, isKeyboard=false, isMouse=true; se keydown foi mais recente, o inverso
    Assim, as funções isInvokedByMouse e isInvokedByKeyboard não seriam necessárias. Existe uma forma melhor? Depender de coordenadas da tela para isso me parece muito suspeito e uma gambiarra

  • Muito interessante, mas não entendo por que o navegador reporta coordenadas diferentes dependendo do monitor. Eu achava que o navegador tratava a página web como se ela estivesse em tela cheia, independentemente de qual display estivesse usando
    Há algum motivo para uma API Web ter esse tipo de informação? Parece um risco de segurança, vazamento de informações e rastreamento

  • Não é uma questão de competência de desenvolvimento? Deveriam ter usado coordenadas do viewport, não coordenadas da tela, e lido isso com .clientX e .clientY. Não entendo por que valores negativos no espaço da tela seriam um bug
    https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_view/...