wandres.dev
GSAP XII · Integración y limpieza

Las fugas que sobreviven al desmontaje

Qué sigue vivo cuando un componente desaparece, cómo detectarlo con tres comprobaciones, y el catálogo de causas ordenado por frecuencia.

⏱ 18 min

Es el bug más caro de este track y el que menos se parece a un bug. No hay error en consola, no hay nada roto en pantalla, y en la sesión de desarrollo —que consiste en cargar una página, mirarla y recargar— es invisible. Aparece en producción, en la sesión de un usuario que ha navegado por veinte rutas, y se manifiesta como que “la aplicación va lenta después de un rato”. Diagnosticar eso sin saber qué buscar puede llevar días; sabiendo qué buscar, son tres comprobaciones de dos minutos cada una.

🎯 Al terminar esta lección sabrás
  • Enumerar qué objetos de GSAP sobreviven al desmontaje de un componente y por qué.
  • Detectar una fuga con tres comprobaciones reproducibles.
  • Reconocer las cinco causas ordenadas por frecuencia.
  • Aplicar el remedio adecuado a cada causa.

Qué sobrevive y por qué

Cuando un componente se desmonta, su parte del DOM desaparece. Lo que no desaparece es todo lo que tenga una referencia viva desde fuera de ese subárbol.

Un tween activo está registrado en el motor global de GSAP, que lo evalúa en cada tick. Aunque su objetivo ya no esté en el documento, el tween sigue existiendo, sigue calculando valores y sigue escribiendo estilos en un nodo desconectado. Y como mantiene una referencia a ese nodo, el nodo entero —y todos sus descendientes— no se pueden recolectar.

Una instancia de ScrollTrigger está registrada en la lista interna del plugin y tiene escuchas de scroll y de resize activas. Sigue comprobando su intervalo en cada actualización, y mantiene referencias al elemento disparador, al fijado y a la animación asociada.

Un Draggable tiene escuchas de puntero sobre su objetivo. Un Observer tiene las suyas sobre el suyo. Un SplitText con autoSplit tiene observadores del ancho y una suscripción a la carga de fuentes.

Todos ellos comparten el mismo patrón: hay un registro global que los apunta, y ese registro es lo que impide que se los lleve el recolector.

ℹ️
Una timeline pausada no es un problema, un tween infinito sí

Un tween que ha terminado se retira solo del motor y deja de costar. El problema son los que nunca terminan: repeat: -1, cualquier cosa atada a un ScrollTrigger vivo, y los que se quedaron a medias porque el componente murió durante la animación. Esos tres son los que se acumulan.

Tres comprobaciones

La primera y más rápida: contar instancias. Deja la consola abierta y navega por tu aplicación entrando y saliendo de la misma vista diez veces.

setInterval(() => {
  console.log(
    "ST:", ScrollTrigger.getAll().length,
    "| tweens:", gsap.globalTimeline.getChildren(true, true, false).length
  );
}, 2000);

Si los números vuelven a su valor de partida al salir de la vista, no hay fuga. Si solo suben, la hay. Es tan simple como eso, y descarta o confirma el problema en un minuto.

La segunda: nodos desconectados. En el panel de memoria de las herramientas de desarrollo, toma una instantánea del montículo, navega diez veces entrando y saliendo, fuerza la recolección de basura y toma otra. Filtra por “Detached” y busca elementos del componente que ya no debería existir. Cada nodo desconectado retenido tiene una cadena de referencias que la propia herramienta te muestra, y ahí verás quién lo está sujetando.

La tercera: el rendimiento por sesión. Graba un perfil de rendimiento después de haber navegado veinte veces y compáralo con uno recién cargado. Si el tiempo de la tarea de animación por frame ha crecido, tienes instancias acumuladas trabajando de fondo.

Las cinco causas, por frecuencia

Primera: no limpiar nada. El componente crea animaciones en su montaje y no hace nada en su desmontaje. Es la causa mayoritaria y la más fácil de arreglar: un contexto y un revert().

Segunda: limpiar solo lo que se ve. Se mata la timeline principal y se olvida la instancia de ScrollTrigger que la controlaba, o al revés. Matar una animación no mata su ScrollTrigger, y matar un ScrollTrigger no mata su animación salvo que se lo pidas.

// Incompleto: la instancia sigue viva
tl.kill();

// Completo
tl.scrollTrigger?.kill();
tl.kill();

// Mejor: que lo haga el contexto
ctx.revert();

Tercera: lo creado de forma diferida. El componente usa un contexto correctamente, pero crea animaciones dentro de manejadores de eventos, de temporizadores o de promesas. Esas quedan fuera del contexto porque se crearon después de que su función terminase. El remedio es ctx.add() con nombre, o contextSafe en React.

Cuarta: las escuchas del DOM. El contexto no las conoce. Cada montaje añade una y ninguna se retira, así que al décimo montaje hay diez manejadores respondiendo al mismo clic. El síntoma característico es una animación que se acelera o se vuelve errática con el uso, porque diez tweens compiten por la misma propiedad.

Quinta: instancias globales. Un ScrollSmoother, un Observer sobre window, o un ScrollTrigger sobre el body creados dentro de un componente. Solo puede existir un ScrollSmoother a la vez, y crear el segundo sin matar el primero produce comportamientos difíciles de diagnosticar.

El patrón que cierra el problema

Sea cual sea el framework, la estructura es siempre la misma: una función que crea y una función que revierte, escritas juntas, con el contexto de por medio.

function montarAnimaciones(contenedor) {
  return gsap.context((self) => {
    const boton = contenedor.querySelector(".abrir");

    // Registrada en el contexto: lo que cree al llamarse queda recogido
    self.add("abrir", () => gsap.to(".panel", { height: "auto", duration: 0.4 }));

    const alPulsar = () => self.abrir();

    gsap.from(".tarjeta", { autoAlpha: 0, y: 30, stagger: 0.08 });

    ScrollTrigger.create({
      trigger: contenedor,
      start: "top 70%",
      onEnter: () => contenedor.classList.add("visto"),
    });

    boton.addEventListener("click", alPulsar);

    // Limpieza de lo que GSAP no conoce
    return () => boton.removeEventListener("click", alPulsar);
  }, contenedor);
}

const ctx = montarAnimaciones(document.querySelector("#seccion"));

// En el desmontaje, sea cual sea el mecanismo del framework:
// ctx.revert();

Y hay una prueba de aceptación que merece la pena convertir en costumbre: entrar y salir de cada vista diez veces y comprobar que los contadores vuelven a cero. Cuesta treinta segundos por vista y es la única forma de que este bug no llegue a producción.

La navegación del lado del cliente

Cuando la aplicación no recarga la página al navegar —un enrutador de SPA, o el router del lado del cliente de Astro— el problema se agrava, porque el estado global persiste entre rutas. Las instancias de la ruta anterior siguen ahí cuando llegas a la nueva, y compiten con las que la nueva acaba de crear.

Además hay un fallo específico de las animaciones de scroll: al cambiar de ruta, la altura del documento cambia por completo, y todas las instancias que sobrevivan tienen intervalos calculados para un documento que ya no existe. El síntoma es una página nueva donde las animaciones se disparan en posiciones aleatorias.

// Barrido defensivo antes de construir la ruta nueva
ScrollTrigger.getAll().forEach((t) => t.kill());
ScrollTrigger.clearScrollMemory();
ScrollTrigger.refresh();

clearScrollMemory() existe justamente para esto: descarta las posiciones de scroll recordadas, que en un enrutador que gestiona la navegación de forma no convencional pueden restaurarse en el momento equivocado.

El barrido es la red de seguridad, no la solución. La solución sigue siendo que cada componente limpie lo suyo; el barrido cubre lo que se te haya escapado.

Esta fuga es cara porque su coste es diferido y su causa es local: los dos rasgos que hacen que un fallo llegue a producción

Merece la pena entender por qué este bug concreto se cuela una y otra vez en equipos que hacen todo lo demás bien, porque el motivo no es descuido sino una combinación de dos propiedades que derrotan a los procesos habituales de calidad. La primera es que el coste es diferido y acumulativo: cada instancia huérfana cuesta una fracción de milisegundo por frame, cantidad que ninguna medición individual detecta, y solo cuando se han acumulado cuarenta el resultado se vuelve perceptible. Todos los mecanismos de detección que usamos —una revisión de código, una prueba manual, una métrica de rendimiento de una carga— observan el sistema en un instante, y este fallo no existe en ningún instante: existe en la derivada. La segunda es que la causa es local y el efecto es global: el componente que olvida limpiar está perfectamente bien por sí solo, pasa sus propias pruebas, y funciona a la perfección en aislamiento; el daño lo sufre la aplicación entera, sin que nada apunte al culpable. Un perfil de rendimiento tomado en el momento del problema muestra tiempo repartido entre docenas de animaciones, todas legítimas en apariencia, y ninguna con el nombre del componente que las dejó huérfanas. Esa combinación —invisible en el instante, invisible en aislamiento— es exactamente el perfil de los fallos que sobreviven a todos los filtros y que acaban descubriéndose por una queja de usuario tres meses después. Por eso la única defensa que funciona no es una revisión más atenta sino un invariante comprobable: el número de instancias vivas debe volver a su valor de partida cuando el componente desaparece. Eso sí es observable en un instante, sí es local, y se comprueba en una línea de consola. Convertir un fallo acumulativo en una aserción puntual es la técnica general que hay detrás de esta lección, y sirve para muchas más cosas que las animaciones.

⚔️ Cazar la fuga
  1. Monta un componente que cree tres ScrollTriggers y no limpie nada. Navega entrando y saliendo diez veces y anota el contador.
  2. Toma dos instantáneas del montículo, fuerza la recolección y busca nodos desconectados retenidos.
  3. Arréglalo con un contexto y verifica que el contador vuelve a cero.
  4. Provoca la tercera causa: crea un tween dentro de un manejador de clic, dentro de un contexto, y comprueba que sobrevive.
  5. Añade una escucha de clic sin retirarla, monta diez veces y observa cómo la animación se vuelve errática.