1 puntos por GN⁺ 2024-11-19 | 1 comentarios | Compartir por WhatsApp
  • En el sitio web de BBC UK, el botón More fallaba al procesar clics solo en un entorno específico de trabajo desde casa; lo que parecía un bug común de UI en realidad era un problema del sistema de coordenadas con varios monitores.
  • Cuando un monitor externo estaba colocado arriba y a la izquierda del monitor principal, screenX y screenY podían ser negativos en el evento click de Chrome y Firefox.
  • El código existente identificaba un clic de puntero con event.screenX > 0 || event.screenY > 0, por lo que no reconocía los clics con coordenadas negativas como clics de mouse.
  • La corrección fue simple: en vez de verificar si screenX y screenY eran mayores que 0, comprobar que no fueran 0, quedando como event.type === 'click' && (event.screenX!== 0 || event.screenY!== 0).
  • Aunque había pasado por pruebas unitarias, Puppeteer, pruebas manuales y pruebas con tecnologías de asistencia, un bug así podía permanecer por la ambigüedad de la especificación UI Events y las suposiciones sobre coordenadas en configuraciones multimonitor.

Un bug de navegación de la BBC que solo se reproducía en un entorno específico

  • La barra de navegación del sitio web de BBC UK abre un menú cuando el usuario activa el botón More.
  • Este botón usa el evento click, que puede generarse no solo con el mouse, sino también con el tacto y con las teclas Enter y Space del teclado.
  • Un integrante del equipo tenía el problema solo cuando usaba su laptop de trabajo en casa; con la misma laptop en la oficina funcionaba correctamente.
  • Incluso en casa, fallaba únicamente cuando la ventana del navegador estaba en el monitor externo; en la pantalla de la laptop, el botón funcionaba bien.
  • Cuando ocurría el problema, en vez de que el handler de JavaScript abriera el menú, este se abría mediante el comportamiento fallback sin JavaScript.
  • En Safari no aparecía el mismo problema.

La condición de reproducción era la posición del monitor

  • El equipo fue acotando las condiciones de reproducción al verificar qué elemento del entorno doméstico causaba el problema.
  • El monitor externo estaba colocado arriba de la pantalla de la laptop, y al cambiar esa disposición en la configuración del sistema operativo, el problema dejaba de ocurrir.
  • Otro integrante del equipo también pudo reproducir el bug al configurar la disposición de monitores del sistema operativo de la misma manera.
  • Las condiciones identificadas al inicio de la investigación fueron dos:
    • En Safari no ocurría el problema.
    • El problema ocurría cuando el monitor externo estaba arriba y a la izquierda del monitor principal.

Coordenadas negativas en screenX y screenY

  • Al inspeccionar con console.log el evento click del botón More, los valores de screenX y screenY aparecían como negativos en Chrome y Firefox.
  • Un evento click, sin importar qué entrada lo haya generado, es un tipo de PointerEvent, por lo que el objeto del evento incluye información del puntero de mouse o táctil que produjo el clic.
  • screenX y screenY representan, en píxeles, las coordenadas del punto donde se hizo clic en la pantalla.
  • En la especificación DOM UI Events no parecía haber información concreta sobre si esas propiedades podían ser negativas.
  • La diferencia entre Safari y Chrome/Firefox muestra que, en configuraciones multimonitor, cada navegador puede tener una forma distinta de representar las coordenadas de pantalla.
  • Este problema de interoperabilidad fue reportado al equipo de WebKit.

Diferencias entre navegadores en coordenadas multimonitor

  • En una configuración multimonitor, el sistema de coordenadas de pantalla del navegador trata varios monitores como si fueran una sola pantalla grande.
  • Si hay dos monitores de 800 px colocados horizontalmente, el rango de la coordenada x podría ir de 0 a 1600.
  • En Safari, el rango de coordenadas parece comenzar siempre en el monitor superior izquierdo, como un rango positivo.
  • En Chrome y Firefox, las coordenadas parecen calcularse tomando como referencia el monitor principal, por lo que en pantallas ubicadas arriba o a la izquierda del monitor principal pueden aparecer coordenadas negativas.
  • Este bug solo ocurría cuando screenX y screenY eran negativos.

El código problemático y la corrección

  • En el código problemático, isInvokedByMouse verificaba si screenX y screenY eran positivos para intentar confirmar que el evento click había sido generado por un puntero de mouse o táctil.
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...
  }
};
  • Este código asumía que screenX y screenY de un evento click generado por un puntero serían positivos.
  • Cuando un usuario hacía clic en el botón More desde un monitor con coordenadas de pantalla negativas, el handler del evento no reconocía el clic y se caía al comportamiento predeterminado del enlace More.
  • La corrección fue comprobar que screenX y screenY no fueran 0, en lugar de verificar si eran mayores que 0.
const isInvokedByMouse = event =>
  event.type === 'click' && (event.screenX !== 0 || event.screenY !== 0);
  • Con este cambio, incluso los usuarios con disposiciones multimonitor poco comunes pudieron usar la barra de navegación del sitio web de la BBC.

Problemas de diseño pendientes y refactorización posterior

  • Aunque la corrección en sí fue sencilla, el código todavía tenía partes extrañas.
  • No era necesario comprobar si click se había generado con mouse o teclado, y el handler de eventos se había vuelto complejo porque también procesaba eventos keydown.
  • Hay que tener cuidado con las suposiciones que se hacen sobre el comportamiento de las API, y el hecho de que la especificación no fuera clara sobre si screenX y screenY podían ser negativos también ayudó a ocultar el problema.
  • Este código había pasado por pruebas unitarias, pruebas con Puppeteer y pruebas manuales con varios navegadores, dispositivos y herramientas de tecnologías de asistencia, pero el bug no se detectó.
  • Como actualización del 19 de noviembre de 2024, el componente de navegación fue refactorizado posteriormente y el handler de eventos del botón menu también cambió de forma importante.
  • El artículo posterior responde preguntas frecuentes y explica la refactorización: How I refactored the BBC navigation bar and a follow-up FAQ

1 comentarios

 
GN⁺ 2024-11-19
Opiniones de Hacker News
  • Para complementar para quienes no hicieron clic hasta el reporte de bug de WebKit: un desarrollador de WebKit le preguntó a la BBC por qué sería útil poder detectar si el evento venía del teclado, y el autor respondió que necesitaban interoperabilidad por casos de uso relacionados con accesibilidad.
    El botón de menú de la barra de navegación del sitio web de BBC Reino Unido se comporta de forma ligeramente distinta cuando se abre con un puntero y cuando se abre con el teclado. Un evento de clic siempre abre el menú, pero si se abre con el puntero, el foco se mueve al contenedor del menú; si se abre con el teclado, el foco se mueve al primer enlace del menú sin la animación de apertura. El evento click es independiente del dispositivo, así que es bueno para crear una experiencia de usuario con teclado, y en el teclado solo se invoca con Space o Enter. Si se usa keydown, hay que comprobar manualmente si es Space/Enter.
    Fuente: https://bugs.webkit.org/show_bug.cgi?id=281430

    • Lo interesante es que, si uno interpreta de forma ingenua el código y la explicación del bug de WebKit en inglés, no encajan con la estructura real del código. El código relacionado es const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0; y const isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);; en la superficie parece que intentan clasificar el evento como una de dos cosas: mouse o teclado.
      En realidad aparecen cuatro categorías: es mouse y no teclado, es teclado y no mouse, es ambos, no es ninguno. Como en el bug original, “no es ninguno” se maneja de forma inadecuada, y también queda la duda de si “es ambos” funciona correctamente. El código debería tratar de forma intencional que “si es teclado” y “si es mouse” son booleanos separados, o estructurarse para que eventSource devuelva categorías mutuamente excluyentes como "keyboard", "mouse", "not sure".
    • Esto no me parece un bug. El primer error del desarrollador fue intentar crear experiencias de usuario distintas para teclado y mouse.
      Es mejor ajustarse al comportamiento predeterminado y diseñar un componente que funcione en ambos casos de uso. En accesibilidad no hay que intentar hacerse el listo. Al final terminó siendo una solución casi de hack, y ese enfoque inevitablemente se rompe o produce efectos secundarios. La razón por la que en el contexto de accesibilidad hay pocos buenos controles para tratar las cosas de forma distinta es que, para empezar, no es un área pensada para tratarse de forma distinta.
    • Este artículo me resulta confuso. Entiendo que la BBC quiere un comportamiento un poco distinto según si es un “clic” de mouse o un “clic” de teclado, y que con teclado quiere enfocar el primer enlace del menú sin animación.
      Al mismo tiempo, también quiere la comodidad de enlazarse a un solo evento. click permite eso, pero como no hay forma de saber si el evento se produjo por un clic de mouse o por entrada de teclado, en Chrome están usando una heurística inestable: si la posición del mouse es screenX=0, screenY=0, se considera que es un clic en el origen o un disparo por teclado. Habiendo trabajado en proyectos de accesibilidad, me parece una idea bastante mala; si la hubiera visto en un PR, habría pedido reescribirla. Sería ideal que los navegadores tuvieran el mismo comportamiento, pero el verdadero problema parece ser que screenX y screenY tienen muy poco significado en un click generado por teclado.
      Idealmente no se debería emitir un MouseEvent, sino un evento más general aplicable tanto a teclado como a mouse, por ejemplo algo como "trigger", que además proporcione información sobre el origen del disparo. Como eso no está en la especificación actual y si se necesita una solución inmediata, sería mucho más estable y menos hacky enlazar también keydown y, cuando ocurra un click junto con un keydown en el mismo elemento, considerarlo entrada de teclado.
    • Entiendo que el autor necesite screenX y screenY, pero sigo preguntándome por qué screenX debería devolver coordenadas reales de pantalla en vez de una posición interna del renderer o de la página renderizada, como layerX, layerY.
      La necesidad del autor también podría satisfacerse con la posición del renderer, sin filtrar la ubicación de la ventana del navegador a todos los sitios web visitados.
    • En “no queremos que el foco y la animación se comporten de forma ligeramente distinta al abrir el menú dependiendo de si el usuario ‘hizo clic’ con un puntero o ‘hizo clic’ con el teclado”, me parece que don’t es un typo que hace que el significado sea el opuesto de la intención.
  • En la parte que dice “solo había que cambiar isInvokedByMouse, que comprobaba si screenX y screenY eran mayores que 0, para que comprobara si no eran 0”, me pregunto qué pasa, aunque sea extremadamente raro, si el usuario realmente hace clic con el mouse en la posición 0,0.
    No estoy familiarizado con JS: ¿la comprobación != 0 es de verdad la mejor o la única forma? Al releer, parece que la frase que dice que el manejador de eventos también procesa keydown, lo cual lo vuelve complejo y debería refactorizarse más adelante, pero que por ahora esta corrección basta, aborda en cierta medida esta parte.

    • Consultar la posición en pantalla parece una heurística creada para determinar la naturaleza del evento. Intuitivamente usaría instanceof MouseEvent, pero eso también se siente riesgoso o como un hack.
      Me pregunto por qué dependen de una heurística así. Puede ser porque toggleMenu se usa desde varios manejadores de eventos, o puede haber otras circunstancias propias de la base de código. Sin ver el panorama completo es difícil juzgar. La respuesta parece estar aquí: https://news.ycombinator.com/item?id=42174177
    • En el código corregido ya se comprueba event.name == 'click'. Entonces no entiendo por qué quieren filtrar algunos eventos de clic normales.
    • No es así. Se puede hacer una selección por medios sobre si el dispositivo de entrada principal es un dispositivo apuntador y, más aún, si es un dispositivo de alta precisión, y filtrar con base en eso.
      Lo he usado antes para elegir qué layout mostrar. Si solo quieres escuchar entrada táctil, puedes hacerlo y luego llamar a preventDefault en el evento para evitar que el navegador genere a continuación un evento click. O simplemente puedes ahorrarte el trabajo y escribir un manejador de clics.
  • Es muy valioso que la BBC haya encontrado un bug desagradable mientras invertía en accesibilidad. Pero ¿por qué la industria todavía no logra hacer bien un dropdown que se abra de forma consistente para todos los usuarios?
    ¿La accesibilidad es tan difícil? ¿La BBC debería haber usado un framework web o un componente web que ya se encargara de estas cosas? Como desarrollador full-stack más orientado al backend, me da cierta cautela tocar componentes del navegador. Hay muchos matices en el comportamiento y las implementaciones llevan mucho tiempo probadas. Por ejemplo, crear un cuadro de texto personalizado sin investigar a fondo el comportamiento de los cuadros de texto en cada plataforma parece una receta para fallar. Incluso en sitios de grandes empresas veo seguido que copiar/pegar se rompe y que faltan caracteres. No entiendo por qué en 2024 se rompen los cuadros de texto, y React ya empieza a sentirse arrogante
    Personalmente, habría intentado resolverlo con plantillas del lado del servidor, un framework CSS como Bulma y un mínimo de JS. No es adecuado para sitios que exigen un branding personalizado muy pulido, pero los cuadros de texto funcionan bien y el costo de desarrollo no es excesivo. No estoy seguro de si cumpliría con el estándar de accesibilidad de la BBC

    • No sé las respuestas a todas las preguntas, pero a “¿la accesibilidad es tan difícil?” puedo responder con certeza que
      Un ejemplo real son los modales. Si no tienes discapacidad visual, puedes ver una caja blanca flotando con componentes de UI adentro, encima de un área gris de “no tocar”. Si usas un lector de pantalla, no hay garantía de que recibas esa información. Cuando te mueves con Tab por los elementos de la UI y vuelves a la parte superior de la caja, ¿cierto lector de pantalla te lo indicará? ¿Listará los elementos interactivos disponibles? ¿Los listará en el mismo orden que otros lectores de pantalla? ¿Y en un teléfono, o en una Mac? ¿El lector de pantalla y el navegador reportarán correctamente los elementos de entrada, o permitirán silenciosamente que el usuario salga del modal y vuelva al resto del sitio?
      En accesibilidad no puedes confiar en que el sistema operativo, el navegador y el lector de pantalla cooperen o se comporten de manera razonable en la situación correcta. En 2019 tuve que reportar un bug en VoiceOver + Safari donde un margin CSS negativo hacía que el lector de pantalla leyera un bloque de texto RTL en orden invertido. Visualmente se veía como 9/10/2019, pero en el lector de pantalla sonaba como “ten slash nine slash two-thousand-and-nineteen”, y como solución temporal tuve que marcar el texto como aria-hidden y agregar una etiqueta p invisible con el orden correcto. Por eso, cuando ves código raro relacionado con accesibilidad, a veces de verdad no hay una mejor forma. Incluso si das vuelta por completo la base de código y pones la accesibilidad como prioridad máxima, puede romperse de forma difícil de entender en cuanto se actualiza JAWS o VoiceOver
    • De acuerdo. Aun así, muchos problemas al final aparecen cuando los user agents personalizan estos elementos de maneras muy dudosas
      En general está bien, pero los archivos reset.css existen por una razón, y aquí parece posible que hayan usado un enfoque más extremo para intentar evitar por completo ese tipo de problemas. Estoy tratando de inferir sus decisiones
  • Esto parece un bug autoinfligido derivado de una heurística equivocada. Se asumió que valores positivos de screenX/Y significaban un evento de mouse, y la falta de rastreo/logs hizo que la investigación fuera más complicada
    En vez de revisar pointerType, que es la propiedad más apropiada sugerida por otros comentarios, me sorprende un poco que la solución del autor sea agregar más heurísticas frágiles. Es como si, a partir de las dos pistas finales, hubiera concluido que al revisar las coordenadas screenX y screenY hay que comprobar no solo valores positivos, sino también negativos

    • En realidad eso es lo que se hará. Pronto se va a fusionar el código para usar pointerId === -1 y luego hacer fallback a screenX === 0
      Hace unos 4 años, cuando este código se escribió por primera vez, no todos los navegadores usaban PointerEvent para click
  • Para empezar, no entiendo por qué un sitio web puede obtener la posición del mouse en el sistema de coordenadas de la pantalla

    • Busqué motivos, pero no encontré mucho. Que un sitio web pueda saber la posición de la ventana del navegador con window.screenX/window.screenY y que la ubicación de un clic también pueda reportarse en ese sistema de coordenadas suena absurdo en escritorio
      TOR Browser parece camuflar screenX y screenY para evitar fingerprinting. Me pregunto si alguien ha visto un buen caso de uso para esta función. Se me ocurren cosas como una aplicación de doble ventana con ventanas que interactúan entre sí, o un sitio cuyo comportamiento cambia según su posición en una pantalla virtual
    • Es útil cuando haces un juego compuesto por varias ventanitas de navegador que interactúan entre sí
      Ejemplo: https://youtu.be/3al8prbfK5o?si=loNtyqIfMFkppm5V
    • Porque fue fácil de implementar durante los 10 días asignados para desarrollar JavaScript en 1995, y desde entonces ha funcionado la retrocompatibilidad :(
    • Si estás respondiendo a un evento de clic, puede que quieras conocer las coordenadas del lugar donde se hizo clic. Se usa sobre todo en operaciones de clic y arrastre, calculando el delta entre eventos para actualizar la posición del objeto arrastrado
      No entiendo por qué revisan coordenadas en vez de revisar event.type. Aun así, el artículo en sí es un buen rompecabezas, y me resulta familiar verme frente a código que no escribí preguntándome “¿por qué importa que las coordenadas del clic no sean 0?”, “¿no bastaría con verificar que event.target sea el botón que se intenta activar?”, “¿por qué usar JavaScript si se puede hacer lo mismo con las etiquetas details/summary?”
    • Se usa para CAPTCHA sin JavaScript. Funciona bien, y al hacer clic solo envía la x y la y del clic del mouse
  • Para empezar, ¿por qué filtrar por coordenadas de pantalla? ¿Qué pasa si el usuario usa un dispositivo de entrada alternativo que no tiene pantalla?
    El evento click por sí solo ya es una señal suficiente de que el usuario intentó activar el menú. No entiendo por qué reinventan la rueda

    • Según el texto, isInvokedByMouse verificaba si las coordenadas screenX o screenY eran positivas para determinar si el evento click fue invocado por un mouse o puntero táctil, y no por el teclado
      Intentaban detectar si la activación venía del teclado o del mouse, y el autor asumió que las coordenadas de pantalla de un evento de mouse siempre serían positivas
  • Publiqué otra entrada en el blog para explicar el contexto que la gente quería entender y responder preguntas. Expliqué por qué al principio comprobé screenX === 0, por qué quería comportamientos distintos según la entrada fuera de teclado o de mouse, y cómo refactoricé para evitar más incidentes.
    Espero que sirva: https://www.joshtumath.uk/posts/2024-11-18-how-i-refactored-...

  • ¿Cuál sería la forma correcta de comprobar si fue un clic de mouse o un clic de teclado? Creo que me darían ganas de establecer una bandera a nivel de módulo según el evento más reciente: si mousedown fue más reciente, isKeyboard=false, isMouse=true; si keydown fue más reciente, al revés.
    Entonces ya no harían falta las funciones isInvokedByMouse e isInvokedByKeyboard. ¿Hay una forma mejor? Depender de las coordenadas de pantalla para esto me parece muy sospechoso y un hack.

  • Muy interesante, pero no entiendo por qué el navegador reporta coordenadas distintas según el monitor. Pensaba que el navegador trataba la página web como si ocupara toda la pantalla, sin importar en qué display estuviera.
    ¿Hay alguna razón para que una API web tenga esta información? Parece un riesgo de seguridad, filtración de información y rastreo.

  • ¿No es un problema de capacidad de desarrollo? Deberían haber usado coordenadas del viewport, no coordenadas de pantalla, y leerlas con .clientX y .clientY. No entiendo por qué sería un bug que haya valores negativos en el espacio de la pantalla.
    https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_view/...