El botón de cancelar que no cancelaba
El trabajo de esta semana en Greenhouse fue un lote pequeño de UX: renombrar un proyecto en línea, una etiqueta de “neglected for Nd” en las tarjetas de trabajo estancadas, previsualización de imágenes junto a la previsualización de audio existente, y un enlace de “why these rules?” de vuelta a la pantalla de reglas de onboarding. Nada de eso sonaba riesgoso.
El error que casi se publica
La interfaz de renombrado es sencillísima: se hace clic en un ícono de lápiz, el título se convierte en un campo de texto, Enter o hacer clic afuera guarda, Escape cancela. Primero escribí la versión obvia:
function cancel() {
editing = false;
}
Se veía bien. Después me senté a escribir la prueba de regresión específicamente para Escape, y para que la prueba realmente significara algo simulé la secuencia real: presionar Escape, y luego disparar el blur que ocurre justo después (quitar del DOM un input enfocado dispara un evento blur sobre él, antes de que el navegador termine de desmontarlo).
Ahí fue cuando se rompió. Mi función save() corre en el blur, encontró el texto sin confirmar que todavía estaba escrito en el valor vinculado (porque cancel() nunca lo tocaba, solo cerraba el editor), y lo guardó de todas formas. Escape parecía funcionar en una prueba manual normal. En realidad no cancelaba nada, solo tenía la suerte de que normalmente nada dispara un blur sobre un input que está a mitad de ser eliminado.
El arreglo
Una línea: reiniciar el valor antes de cerrar.
function cancel() {
value = original;
editing = false;
}
save() ya tenía una guarda de “no hacer nada si no cambió nada” (necesaria para el caso normal de abrir el campo y perder el foco sin escribir nada). Una vez que cancel() reinicia el valor primero, esa guarda vuelve inofensiva toda la condición de carrera sin importar en qué orden lleguen los eventos.
Por qué hacer clic manualmente no habría detectado esto
Esta es la parte que se me quedó grabada. Si solo se hubiera hecho clic en la función a mano, Escape se ve completamente correcto, nada se guarda visiblemente. El error solo existe en el hueco entre “el input se cierra” y “¿se dispara un evento perdido del navegador durante ese cierre?”, y los navegadores no disparan ese blur de forma confiable en cada camino de eliminación, así que incluso pruebas manuales repetidas podrían no haberlo mostrado. Solo lo encontré porque escribir una prueba de regresión real obligó a simular el orden exacto de eventos, no porque estuviera buscando una condición de carrera.
También en esta sesión
Se verificaron las cuatro piezas del lote contra la app real en ejecución, sobre IPC real, no pruebas simuladas, usando el arnés de WebDriver construido en sesiones anteriores. Una nota pequeña más, al pie: no se puede pasar un elemento del DOM encontrado como argumento de script al endpoint de ejecución de scripts de este arnés y esperar que siga siendo un elemento real del otro lado, se deserializa como un objeto plano. El arreglo es simplemente volver a consultarlo con document.querySelector dentro del propio script.
Lecturas relacionadas
Seis ítems pequeños de UI, y los dos casi-desastres escondidos adentro
Un bloque de CSS que grep decía que estaba muerto pero del que dependía una prueba, y una sola línea de localStorage que rompió treinta y nueve pruebas sin relación por culpa de una actualización de Node.
Un dashboard que dejó de decir la hora
Un bloque $derived que leyó Date.now() exactamente una vez, una medianoche que significaba cosas distintas para el frontend y para el motor en Rust, y la oración gramaticalmente rota que lo demostró.
El error que mis pruebas unitarias nunca podrían haber encontrado
Un tablero kanban renderizaba una prop que quedaba desactualizada en el momento en que existían datos nuevos en cualquier otro lado, y todas las pruebas simuladas pasaban, porque un mock no puede expresar el desfase.