La mayoría de los bugs de UI son cosméticos. Un par de ellos cambian números que el usuario no quería cambiar, y si esos números son apalancamiento, tamaño de orden o un precio, el bug ya no es cosmético.
Estos son dos que nos tocaron, ambos causados por comportamientos del navegador y de la librería que están técnicamente documentados y son prácticamente invisibles. Las soluciones son pequeñas. Encontrarlas no lo fue.
Puntos clave
- Un <input type="number"> con foco incrementa o decrementa cuando desplazas la rueda sobre él. En un campo de dinero, esto reescribe el valor en silencio.
- Corrígelo con blur(), no con preventDefault(): quitar el foco cancela el incremento y deja intacto el desplazamiento de la página y del modal.
- Un popover de Radix con portal a document.body no puede mantener el foco dentro de un diálogo de Radix, porque el focus scope del diálogo devuelve el foco hacia afuera. El campo parece vivo y se come cada tecla.
- Pasa el propio elemento del diálogo como contenedor del portal del popover para que forme parte del subárbol atrapado.
- Esa corrección invalida los manejadores de clic-fuera hechos a mano: una vez que el contenido va por portal, deja de ser un descendiente en el DOM del contenedor contra el que medías.
1. La rueda es un gesto de edición, y nadie se lo dijo al usuario
Los inputs numéricos nativos tienen un comportamiento que viene de la especificación: cuando el campo tiene foco, desplazar la rueda del ratón sobre él sube o baja el valor según su atributo step.
En un selector de cantidad de un carrito de compras, eso es una molestia menor. En nuestros formularios es algo completamente distinto, porque los campos en cuestión son apalancamiento, monto de trading, porcentaje de reparto de ganancia y precio de listado. El usuario hace clic en un campo, desplaza la página para leer el resto del formulario, y el número que ya había fijado ha cambiado. Nada lo anuncia. El formulario sigue validando. El bot arranca con un valor que el usuario nunca eligió.
La solución ingenua es enganchar un manejador wheel en cada input y llamar a preventDefault(). Eso funciona, y también rompe el desplazamiento: el usuario ya no puede desplazar la página mientras el cursor pasa por encima de un campo numérico, lo que se siente roto de una forma completamente distinta.
La mejor solución usa la precondición de la especificación. El incremento solo ocurre cuando el campo tiene foco, así que basta con quitar el foco:
const onWheel = (e) => {
const active = document.activeElement;
if (
active instanceof HTMLInputElement &&
active.type === "number" &&
(active === e.target || active.contains(e.target))
) {
active.blur(); // kills the increment, leaves scrolling intact
}
};
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
Tres decisiones deliberadas en ese fragmento:
blur()en lugar depreventDefault(). No estamos cancelando el desplazamiento, estamos quitando la condición que lo convierte en una edición. La página sigue desplazándose con normalidad.capture: true. El listener observa el evento en la fase de bajada, así que se dispara aunque algún componente interno detenga la propagación.passive: true. Seguro precisamente porque nunca llamamos apreventDefault(), y mantiene el listener fuera de la ruta crítica del desplazamiento.
Un único listener global, montado una sola vez, cubre cada input numérico de la app, incluidos los que todavía no existen. La alternativa, parchear cada campo, falla en el momento en que alguien añade un formulario nuevo y se olvida.
2. Un popover dentro de un diálogo en el que no se puede escribir
El segundo se presentó como un reporte de bug imposible: una caja de búsqueda dentro de un desplegable, dentro de un modal, que visiblemente recibía el foco y luego ignoraba todo lo que se escribía en ella.
La causa son dos comportamientos correctos que chocan.
Los popovers de Radix envían su contenido por portal a document.body por defecto, lo cual es correcto en una página normal, porque escapa de los contextos de overflow y de apilamiento. Los diálogos de Radix atrapan el foco dentro de su propio subárbol, lo cual también es correcto, y es lo que hace que un modal sea un modal.
Ponlos juntos y el contenido del popover está, en términos de DOM, fuera del diálogo. El focus scope del diálogo revisa cada evento de foco entrante más o menos así:
container.contains(target) ? allow : focus(lastFocusedElement)
La caja de búsqueda no está contenida por el diálogo, así que cada evento de foco que aterrizaba en ella se deshacía de inmediato. El campo se renderizaba, mostraba un cursor por un instante, y perdía el foco antes de que se pudiera registrar una tecla. Parecía vivo y se lo tragaba todo.
La solución es dejar de mentir sobre dónde vive el popover. El portal de Radix acepta un container, así que renderizamos un div vacío de altura cero dentro del diálogo y enviamos el popover por portal ahí:
// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>
Dos detalles fáciles de equivocar:
- Estado, no una ref. El nodo host no existe en el primer render, y el portal necesita un re-render una vez que existe. Un
useRefguardaría el nodo pero nunca dispararía ese render. - El host no debe afectar el layout. Está vacío y tiene altura cero, así que puede ubicarse en cualquier lugar conveniente del markup del diálogo sin desplazar nada.
3. La corrección que rompe en silencio el código de al lado
Esta es la parte que señalaría con más fuerza, porque es donde una corrección limpia se convierte en una regresión.
Antes de pasar a un popover con portal, el desplegable tenía un manejador de clic-fuera hecho a mano: mantener una ref en el contenedor, escuchar los clics del documento, cerrar si el clic no estaba dentro.
Una vez que el contenido va por portal, deja de ser un descendiente en el DOM de ese contenedor, aunque visualmente esté dentro de él. Así que ahora el manejador ve los clics en la propia caja de búsqueda del desplegable como clics fuera, y cierra justo lo que el usuario está intentando usar.
Quitar ese manejador no fue limpieza, fue parte de la corrección. La regla general:
El portal rompe cada suposición basada en la contención del DOM. Los manejadores de clic-fuera, las búsquedas con
closest(), la delegación de eventos y los selectores CSS de descendiente cambian todos de significado en silencio. Cuando envíes algo por portal, ve a buscar el código que asumía contención.
Qué tienen en común estos dos
Ambos bugs viven en la brecha entre "la librería se comporta correctamente" y "el usuario está pasando un mal rato." Nada lanzó una excepción. Nada quedó en el log. En ambos casos el componente se veía bien en una captura de pantalla.
Las lecciones generales que me quedaría:
- Sabe qué gestos trata la plataforma como ediciones. La rueda sobre un input numérico con foco es uno; también lo es el pegado con clic central en Linux. Si un campo contiene dinero, audítalos.
- Prefiere quitar una precondición antes que cancelar un evento.
blur()le ganó apreventDefault()porque atacó la causa en lugar de suprimir el síntoma, y dejó intacto el comportamiento no relacionado. - Resuélvelo una vez, de forma global, cuando el modo de fallo es "alguien se olvida." Una corrección por campo es una corrección con fecha de caducidad.
- Cuando uses portal, audita las suposiciones de contención. El árbol del DOM y el árbol visual dejan de coincidir, y cualquier código que los confundía ahora está equivocado.
