
Nacido y arreglado en doce minutos
El changelog de GeekBye v1.8.10 dice que arregló un crash al editar los atajos de teclado. Lo hizo — pero el crash se introdujo y se arregló en el mismo pull request, con doce minutos de diferencia, y nunca llegó a un solo usuario. La historia real es la cascada de fiabilidad que lo produjo: un cambio pequeño y correcto sobre cuándo se suspenden los atajos, y el bug de teardown de React que se desprendió de una línea pensada para hacer el editor más seguro.
Esta es la frase más honesta que puedo escribir sobre GeekBye v1.8.10: el crash por cuyo arreglo es famosa nunca le pasó a nadie. El changelog dice "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit", lo cual es cierto, y se lee como un bug que se publicó, mordió a los usuarios, y se parcheó en un follow-up. No lo fue. El crash lo introdujo un commit y lo borró otro en el mismo pull request, con doce minutos de diferencia, la misma mañana de un viernes. Vivió por completo dentro de una rama. Las notas de la versión describen una cicatriz que se formó y sanó antes de que nadie fuera del repo pudiera verla.
Eso no es un fallo del changelog; es la forma honesta del trabajo iterativo, y vale la pena contarlo porque esos doce minutos contienen un bug de React genuinamente instructivo. Pero para entender el crash tienes que entender para qué era realmente la release — porque el crash fue daño colateral de arreglar otra cosa.
De qué iba realmente la release
GeekBye te deja reasignar sus atajos de teclado en Ajustes. Mientras grabas una combinación nueva, la app tiene que impedir que se disparen sus atajos globales — de lo contrario, pulsar Cmd+B para asignarlo simplemente alternaría la ventana en vez de ser capturado. Así que el editor suspende los atajos globales por IPC: el renderizador llama a window.electronAPI.setShortcutsSuspended(true), y el ShortcutsHelper del proceso principal responde llamando a unregisterAll(), soltando cada registro de globalShortcut para que la pulsación llegue al input de captura en vez de disparar una acción. Al reanudar llama a registerGlobalShortcuts() para volver a ponerlos.
El bug que empezó todo esto (issue #233) tenía que ver con el alcance de esa suspensión. El código antiguo suspendía los atajos durante todo el tiempo que la página de ajustes de atajos estuviera abierta — no solo mientras grababas activamente una tecla. La mayor parte del tiempo eso es invisible. Pero si abrías la página, te ponías a hacer otra cosa, y te ibas sin llegar a terminar una edición, la app podía quedarse con todos sus atajos globales sin registrar — muertos en silencio — hasta que volvieras. El commit que lo arregla es franco sobre el síntoma: suspender de forma demasiado amplia arriesgaba "making the app appear broken if the user forgot they had the page open."
El arreglo es un estrechamiento de una línea: suspender solo cuando una fila se está editando de verdad — con clave en !!editingShortcut, el estado del hook que guarda el id de la fila que estás grabando — en vez de durante toda la visita. Buen cambio. Pero «suspender solo mientras se edita» plantea una pregunta inmediata: ¿qué cuenta como terminar una edición? Pulsar una tecla la termina. Pulsar Escape la cancela. ¿Pero qué pasa si simplemente… haces clic fuera, o cambias a otra app por completo, a mitad de captura? Si nada cancela la edición, te quedas grabando un atajo en una ventana que no tiene el foco, y los atajos siguen suspendidos. Así que el mismo PR añadió una válvula de seguridad: cancelar la edición cuando el foco se va. Y esa es la línea que crasheó.
El bug de doce minutos
La válvula de seguridad era un useEffect en ShortcutsSettings.tsx que, mientras una fila se estaba editando, escuchaba el blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Dos listeners. Uno en la window — se dispara cuando cambias a otra app. Uno adjuntado directamente, como un listener del DOM crudo, al <input> de captura — pensado para dispararse cuando haces clic fuera hacia otro elemento. Parecen redundantes e inofensivos. No lo son.
Recorre la secuencia de «switching windows mid-edit», que es exactamente la frase del changelog:
- Estás grabando un atajo; el
<input>tiene el foco. Haces Cmd+Tab a otra app. - El blur de la ventana se dispara.
handleBlurejecutacancelEditing(), que devuelveeditingShortcutanull. - Fijar ese estado desmonta el
<input>— la fila sale del modo edición, así que el input se elimina del DOM. - Eliminar del DOM un elemento con el foco despacha síncronamente un evento
blursobre ese elemento. El listener crudo deinputReflo captura y llama acancelEditing()otra vez — esta vez en mitad de la fase de commit de React, mientras el árbol de componentes se está desmontando. - Llamar a un setter de estado sobre un fiber que está a mitad de teardown activa uno de los invariantes internos de React, y lanza:
"Should have a queue". La vista de ajustes crashea.
El bug no es el listener de la ventana y no es realmente el concepto de «cancelar al perder el foco». Es que uno de los dos listeners era un addEventListener crudo sobre un elemento gestionado por React. React no sabe de ese listener, no lo limpia al desmontar, y — de forma crucial — el navegador dispara el blur durante la misma eliminación del DOM que React está realizando, así que el handler reentra en tu lógica de estado en el peor instante posible. El mensaje del commit del arreglo lo dice con más precisión de la que yo puedo: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
El arreglo
bf28a50, doce minutos después de que naciera el crash, hace dos cosas pequeñas.
Primero, borra el listener crudo del input y conserva solo el de la ventana, que es el único que necesitaba ser un listener manual del DOM (no hay un onBlur de React para «toda la ventana perdió el foco»):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Segundo, mueve el caso de clic-fuera — «el usuario hizo clic en otro elemento» — a la propia prop onBlur de React sobre el input, y lo difiere un tick:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Ambas partes importan. Usar la prop onBlur de React en vez de un listener crudo significa que React es dueño del ciclo de vida del handler y no lo dejará colgando. Y el setTimeout(…, 0) garantiza que, incluso cuando este blur sí lo dispara un desmontaje, la llamada a cancelEditing() aterriza en el siguiente tick — después de que el render y el commit actuales hayan terminado — así que nunca puede actualizar el estado de un componente que todavía se está desmontando. Si el componente ya no está para entonces, la llamada diferida es un no-op inofensivo.
Ese es todo el arreglo. Borra el listener que React no podía ver; deja que React planifique el que queda.
Por qué este es el buen tipo de aburrido
Hay una tentación de archivar esto como «trivial» — un setTimeout, una línea borrada — y seguir adelante. Pero su forma es una de las maneras más comunes en que las apps de escritorio en React crashean, y vale la pena interiorizarla:
- El crash solo aparece en un límite del ciclo de vida (desmontaje), disparado por un evento que el framework no planifica (un blur nativo durante la eliminación del DOM). No aparecerá en un test de clics; necesita la ruta específica de «el foco se va mientras se edita».
- La causa raíz es mezclar dos modelos de propiedad — eventos del DOM crudos y los eventos sintéticos y planificados de React — sobre el mismo elemento. En el momento en que tu handler muta un estado que puede desmontar ese elemento, la ruta cruda se vuelve reentrante.
- El arreglo no es «añadir un flag de guarda» (aunque un ref
isMountedlo habría enmascarado); es quitar el listener crudo por completo, que es el cambio más pequeño y el correcto. Resta, otra vez.
Y la meta-lección, la que toda esta serie no deja de rondar: lee el diff, no la nota de la versión. "Fixed a crash while editing keyboard shortcuts" es exacto, pero oculta que el crash fue autoinfligido, de la misma mañana, y que nunca se publicó — y que la release real es una victoria silenciosa de corrección sobre cuándo suspender un atajo global.
Qué más se publicó
Otras dos cosas viajaron junto en la v1.8.10, ambas merecen una línea. El sistema de atajos también ganó autorreparación a través del ciclo de suspensión/reactivación: macOS puede descartar en silencio los registros de atajos globales de una app cuando la máquina se suspende, así que un handler de reanudación ahora los vuelve a verificar y a registrar, lo que significa que tus atajos siguen funcionando después de que el portátil despierta sin necesidad de reiniciar. Y del lado de la IA, el cliente se volvió más listo con los límites de tasa — las peticiones en streaming que topan con un 429 ahora hacen back off exponencial y reinician su contador cuando dejas de grabar, en vez de esperar un retardo fijo. Tres arreglos de fiabilidad en subsistemas distintos, uno de los cuales es el crash que nunca fue un crash.
Para el capítulo anterior de la historia v1, imprimir una reunión a PDF sin una librería de PDF (v1.8.9); y para todo el arco, la anatomía de publicar software hasta la perfección.