Arreglar una carrera de concurrencia, un bug de la tecla Esc, y un crash por clave duplicada en Greenhouse
Una tanda de arreglos de robustez y accesibilidad para Greenhouse, mi app de escritorio en Tauri para gestionar proyectos creativos. Nada de esto es glamoroso, es el tipo de pasada de limpieza que toda app necesita después de que aterrizan las primeras funcionalidades y empiezan a notarse las asperezas.
La carrera que nadie habría detectado leyendo el código una sola vez
Greenhouse tiene una función de “refrescar el día” que vuelve a traer el estado desde el backend en Rust después de casi cualquier acción, capturar una idea, archivar un proyecto, registrar un touch. Se llama refreshDay, y es sumamente simple: llamar al backend, obtener el estado nuevo, asignarlo.
El problema apareció cuando miré de cerca qué pasa al archivar un proyecto desde su vista de detalle. Esa acción dispara dos cosas seguidas: un callback onChanged y un callback onClose, y ambos llaman a refreshDay. Así que salen casi simultáneamente dos solicitudes por el estado del día. Nada garantiza que vuelvan en el orden en que se enviaron.
Si la primera (emitida antes, y por lo tanto con datos un poco más viejos) llega a resolverse después de la segunda, su respuesta desactualizada sobrescribe la fresca, y un proyecto recién archivado puede volver a aparecer en la vista. Archivarlo de nuevo duplicaría la marca de la decisión de archivado.
La solución es un patrón que ya había usado antes pero nunca había necesitado acá: un contador de secuencia monotónico. Cada llamada a refreshDay incrementa un contador y recuerda su propio número. Cuando llega la respuesta, solo se aplica si su número todavía coincide con el último emitido. Las respuestas tardías y desactualizadas simplemente se descartan.
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.
}
}
Lo que hizo esto satisfactorio (y un poco humillante) fue escribir una prueba de regresión para esto y que mi primera versión pasara incluso con la corrección quitada temporalmente. La prueba verificaba una condición que ya era cierta antes de que ninguna de las llamadas en carrera se resolviera, así que en realidad no estaba probando nada. Solo lo detecté porque tengo el hábito de romper la corrección a propósito y volver a correr la prueba, si una “prueba de regresión” no puede fallar, no es una.
La tecla Escape se topa con un botón deshabilitado
Todos los diálogos de Greenhouse deshabilitan su botón Cancelar mientras hay un envío en curso, para que no se pueda disparar una segunda solicitud mientras la primera todavía corre. Bastante razonable, salvo que los elementos <dialog> nativos responden a la tecla Escape de forma independiente a cualquier botón, y eso no se había tenido en cuenta. Si se presiona Escape mientras una captura se está guardando, el diálogo se cierra de todas formas, sin importar que el botón Cancelar esté deshabilitado, y cualquier estado que debía actualizarse al completarse (contador de racha, lista de trabajo) simplemente nunca ocurre porque el callback nunca se dispara.
El elemento dialog en realidad dispara un evento cancel antes de cerrarse, y ese evento es cancelable:
<dialog
oncancel={(e) => {
if (busy) e.preventDefault();
}}
>
Simple una vez que lo encontré, fácil de pasar por alto hasta que alguien (o alguna prueba) realmente machaca Escape en medio de un envío.
Una clave duplicada que solo muerde justo en el peor momento
El historial de touches en la vista de detalle del proyecto usaba como clave el timestamp, con resolución de un segundo. Al registrar un touch y avanzar de inmediato la etapa del proyecto (lo cual también registra un touch) dentro del mismo segundo, se obtienen dos entradas de lista con una clave idéntica. A Svelte no le gusta eso, la vista de detalle se cae. La solución fue simplemente usar el índice de la lista como clave en vez del timestamp, que es lo que debería haber sido la clave desde el principio, ya que son entradas ordenadas y de solo agregado.
Detalles sueltos de accesibilidad
De una revisión anterior salieron algunos ítems menores de accesibilidad (a11y): el id del encabezado de una zona se construía directamente a partir del texto del título de la zona, lo cual se rompía para “Ripe today” porque los ids de ARIA no pueden tener espacios. Se convirtió a slug. El contador de ítems junto a cada encabezado de zona tenía aria-hidden, así que quienes usan lector de pantalla nunca escuchaban cuántos ítems había en una zona, se quitó eso. Y el cambio a la vista de “éxito” del diálogo de captura no movía el foco a ningún lado, así que alguien con lector de pantalla quedaba enfocado en un campo de formulario que ya no existía, ahora se enfoca el encabezado de confirmación, y se vuelve a enfocar el campo de nombre al hacer clic en “Capturar otro”.
Verificarlo, y chocar con una pared de forma divertida
Greenhouse tiene una configuración basada en WebDriver para manejar la app real ya compilada (no una prueba simulada), clics reales, viajes de ida y vuelta de IPC reales. La usé para confirmar de verdad en el navegador las correcciones de foco y ARIA, lo cual fue satisfactorio ya que jsdom solo puede aproximar eso.
Intenté usar la misma configuración para enviar una pulsación real de Escape y verificar la corrección del cancelado del diálogo de punta a punta. No funcionó, y en vez de asumir que mi código estaba mal, escribí un pequeño script de sondeo que solo enviaba un carácter imprimible a un campo de texto enfocado y verificaba si aparecía. No apareció. Resultó que el endpoint /actions del plugin de WebDriver todavía no entrega eventos de tecla al webview en la versión que estoy usando. Buen recordatorio para aislar “¿está roto mi herramental?” de “¿está roto mi código?” antes de perseguir una corrección que no hace falta, la lógica de guarda de Esc sigue totalmente cubierta por una prueba unitaria que dispara un evento cancel real del DOM y verifica que se llamó a preventDefault, solo que todavía no está probada contra una pulsación de tecla real del sistema operativo.
Lecturas relacionadas
Rediseño de navegación: pestañas en la barra superior y un diálogo de Settings
Ocho controles de footer con el mismo peso visual, una clave de i18n cumpliendo doble función, y el patrón ARIA que el propio test de a11y del proyecto no dejó construir a medias.
Toast y deshacer para la acción de vault de Greenhouse
La primera superficie de feedback transitorio de la app, la regla de aria-live que hace que un toast renderizado condicionalmente quede sin anunciar en silencio, y un prop que queda desactualizado apenas se renombra algo.
Arreglar el error de manejo de foco de Greenhouse me enseñó la diferencia entre un diálogo y una página
El ticket decía que había que restaurar el foco al elemento que lo disparó. Ese elemento ya no existe, se destruyó en el instante en que se abrió la vista. El patrón correcto era el de una navegación de página, no el de un modal.