el bug que me enseñó qué no es un bug

devlog · Oct 4, 2026 · 7 min read

al hacer clic en blog en la barra lateral cambiaba la url a /blog y dejaba la portada en pantalla. clic en info, lo mismo. el enrutador funcionaba, el historial funcionaba, el manejador de clic funcionaba. solo la imagen estaba mal.

lo arreglé borrando tres cosas a la vez, y he pensado más en esa decisión que en el bug mismo.

el mecanismo, antes del arreglo

esto es lo que era el cuerpo de la página:

<AnimatePresence mode="wait" initial={false}>
  <motion.div
    key={page + (blogPostId || "")}
    initial={settings.disableAnimations ? false : { opacity: 0, y: 15, scale: 0.98 }}
    animate={{ opacity: 1, y: 0, scale: 1 }}
    exit={{ opacity: 0, y: -15, scale: 0.98 }}
    transition={/* duración, easing, muelle — omitido */}
  >
    {page === "home" && <HomePage ... />}
    {page === "blog" && <BlogPage ... />}
    {/* ... */}
  </motion.div>
</AnimatePresence>

lee el key. cambia en cada navegación, así que react desmonta el viejo motion.div y monta uno nuevo, y AnimatePresence existe para mantener el viejo vivo lo suficiente para que reproduzca su exit.

y mode="wait" es la instrucción de no montar aún el nuevo. esa es toda la semántica del modo: el hijo entrante permanece desmontado hasta que el saliente ha terminado de irse. la documentación lo dice claramente, y es un buen modo. te da un corte limpio en lugar de dos páginas peleándose por el mismo espacio.

ahora pon la condición de fallo al lado. la nueva página — eso que el usuario hizo clic, eso que la url ya dice — no aparece hasta que termina una animación. cada fotograma que no se ejecuta es una página que no se renderiza.

las tres cosas que borré, y por qué no puedo decirte cuál lo hizo

git diff src/App.tsx es toda la investigación. tres bloques:

1. el envoltorio de presencia. <AnimatePresence mode="wait" initial={false}> alrededor del cuerpo de la página desapareció. el motion.div con su key se queda, con el mismo key, el mismo initial, el mismo animate, la misma transition — solo se va exit, porque ya no queda nada de qué salir. react sigue remontando en cada cambio de página, así que la animación de entrada todavía se reproduce. la página a la que navegaste ahora la renderiza la reconciliación propia de react, no una animación que termina.

2. React.startTransition en el manejador de navegación. envolvía los dos setters de estado:

React.startTransition(() => {
  setPage(newPage);
  setBlogPostId(postId);
});

y ahora es:

setPage(newPage);
setBlogPostId(postId);

este es un sospechoso real y no una preferencia de estilo. una actualización en transición es, por diseño, interrumpible y de menor prioridad — react puede cederla y retomarla. la actualización que cambia el key es la que gobierna el intercambio de presencia. así que envolverla en una transición hace interrumpible el propio intercambio, y un intercambio interrumpido es exactamente el caso en el que al hijo saliente nunca se le dijo que saliera y al entrante nunca se montó.

3. layout en el envoltorio <motion.main>. eliminado. una proyección de diseño sobre el padre mide y anima la caja del hijo, y se ejecuta como una pasada separada del intercambio de presencia. escribí el motivo en el código en su momento, y todavía lo creo: una proyección de diseño sobre el envoltorio pelea con el hijo de AnimatePresence que es el que realmente cambia de página, y el perdedor puede dejar montada la página anterior.

y ahí está. quité las tres, en un commit, sin biseccionarlas. no pude acotarlo, así que eliminé toda la configuración en la que el invariante podía romperse. eso no es un diagnóstico. es una escopeta, y la etiqueta honesta es «no encontré el bug, eliminé la posibilidad de tenerlo».

la medición que me dijo que el modo de fallo era real

el entorno en el que pruebo — el panel de vista previa que tengo abierto mientras trabajo — no ejecuta ningún fotograma de animación. no son fotogramas lentos. cero.

así es como lo establecí. la versión obvia se queda colgada:

new Promise(res => {
  let n = 0;
  const t0 = performance.now();
  const tick = () => {
    n++;
    if (performance.now() - t0 < 1000) requestAnimationFrame(tick);
    else res({ rafFramesIn1s: n });
  };
  requestAnimationFrame(tick);
});

esto debería resolverse tras un segundo. nunca se resuelve, porque lo único que puede terminarlo es un fotograma, y ningún fotograma llega nunca. la evaluación expira y me dice que la página siguió respondiendo — lo cual es cierto. está respondiendo y completamente inmóvil.

así que pregunté otra vez sin colgarme:

requestAnimationFrame(function tick() { frames++; requestAnimationFrame(tick); });
// ... 2,5 segundos después
{ "rafFramesAfterWait": 0 }

cero fotogramas en dos segundos y medio. lo que significa que en este entorno exit={{ opacity: 0, y: -15 }} nunca puede completarse, mode="wait" nunca puede liberar el hijo entrante, y la página correcta es inalcanzable de forma incondicional. el bug no era intermitente allí. era seguro.

qué no es un bug

esta es la parte en la que quiero tener cuidado, porque la conclusión fácil es incorrecta.

no es un bug de AnimatePresence. ese componente sigue en este código once veces repartido en seis archivos, y todos funcionan:

dónde qué gobierna mode="wait" por qué es seguro
HomePage ×2 las palabras del titular giratorio decoración; si se atasca ves la palabra anterior
BlogPage el bloque de la entrada destacada contenido secundario en una página que ya se renderizó
Code.tsx la etiqueta de lenguaje de un bloque una insignia
CopyLinkCapsule ×2 la etiqueta «copiado!» una confirmación de 400 ms
SettingsDialog ×4 el panel detrás de una pestaña la pestaña ya te dice dónde estás
TechStack un elemento de la lista el resto de la lista ya está ahí

ni uno de ellos gobierna una ruta. y esa es la regla real que debería haber escrito antes del bug en lugar de después: mode="wait" es seguro exactamente cuando lo gobernado es opcional, y es un riesgo de corrección cuando lo gobernado es el destino. si lo que no aparece es justo lo que el usuario pidió, has convertido un easing en una precondición.

tampoco es un bug de la animación. borrar la animación habría «arreglado» el bug — activa disableAnimations y el exit termina al instante — y habría sido el arreglo equivocado por partida doble: la animación nunca fue el defecto, y el sitio habría perdido su transición para tapar un problema de gestión de estado. la transición no es lo que estaba vendiendo. es decoración. no debería haber podido vetar una ruta.

entonces, ¿cuál era el bug? un invariante, escrito en el idioma equivocado. la propiedad que necesitaba es «la página a la que navegaste es la página que obtienes». la había expresado como «la página a la que navegaste aparece después de que la anterior termine de animarse». la primera es una afirmación sobre corrección que el reconciliador de react garantiza. la segunda es una afirmación sobre sincronía que depende de un navegador ejecutando fotogramas. había hecho que una propiedad de corrección dependiera de una animación, y luego pasé un día buscando un bug en la biblioteca de animación.

cómo sé que el arreglo funciona

en ese mismo entorno sin fotogramas, porque un arreglo que solo funciona donde se ejecutan fotogramas no es un arreglo:

  • home → blog: url /blog, #primary-content.children.length === 1, página de blog renderizada
  • blog → home: url /, children.length === 1, portada renderizada

un hijo, siempre. ese número es toda la prueba — es el observable directo de lo que estaba roto, y es lo que comprobaba a mano antes. kids: 1, url y contenido de acuerdo, sin que se ejecute ni un fotograma.

qué le diría a mi yo del pasado

  • nombra el invariante, no la biblioteca. «la url cambió y la página no» es un informe de bug. «AnimatePresence está roto» es un estado de ánimo. el primero apunta a una línea de código; el segundo apunta a tu dependencia favorita.
  • un arreglo que cambia tres cosas no relacionadas no es un diagnóstico. quité el envoltorio, el envoltorio de transición y la proyección de diseño a la vez y lo llamé resuelto. si hubiera quitado uno y medido, sabría cuál era — y habría conservado los otros dos, y esta entrada sería sobre un bug en lugar de sobre una costumbre.
  • si el único sitio donde puedes reproducirlo es el sitio donde también trabajas, tienes un problema de medición antes de tener un bug. gasté este en un panel de vista previa que no renderiza fotogramas. esa es la razón por la que se reproducía y la razón por la que nunca pude verlo en un navegador normal, y significa que no puedo afirmar honestamente que los visitantes reales lo pasan. lo que sí puedo decir es que la condición de fallo es una que un navegador real se encuentra cada vez que una pestaña pasa a segundo plano, se descarta un fotograma a mitad de transición, o el usuario navega más rápido que la animación — y un bug cuyo único detonante es una condición que no puedes ver en tu propio bucle de desarrollo es un bug que se envía.