wandres.dev
GSAP XII · Integración y limpieza

El patrón equivalente en Solid y en Astro con islas

Cómo se traduce el ciclo de montaje y limpieza a Solid con onMount y onCleanup, y a Astro con scripts, islas y navegación del lado del cliente.

⏱ 20 min

El hook de React resuelve un problema que existe en todos los frameworks, así que el patrón se traduce sin perder nada: hace falta un punto donde crear, un punto donde revertir, y un ámbito para los selectores. En Solid esas tres cosas ya existen en el propio framework y no hace falta nada más. En Astro la situación es distinta y más interesante, porque no hay un ciclo de vida de componente en el cliente: hay scripts que se ejecutan una vez, islas que se hidratan cuando les toca, y un router que puede reemplazar el documento entero sin recargar.

🎯 Al terminar esta lección sabrás
  • Traducir el patrón a Solid con onMount y onCleanup.
  • Elegir el momento de creación correcto en una isla de Solid según su directiva de cliente.
  • Escribir animaciones en un script de Astro que sobrevivan a la navegación del lado del cliente.
  • Limpiar antes del intercambio de documento con los eventos del ciclo de vida.

Solid: el ciclo ya lo trae el framework

Solid no reejecuta el cuerpo del componente en cada actualización, así que no hay problema de doble ejecución ni de dependencias. El cuerpo se ejecuta una vez, onMount corre después del primer renderizado, y onCleanup corre al desmontar.

import { onMount, onCleanup } from "solid-js";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

export function Tarjeta(props) {
  let contenedor;
  let ctx;

  onMount(() => {
    ctx = gsap.context(() => {
      gsap.from(".titulo", { autoAlpha: 0, y: 24, duration: 0.6 });

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

  onCleanup(() => ctx?.revert());

  return (
    <article ref={contenedor}>
      <h3 class="titulo">{props.titulo}</h3>
    </article>
  );
}

Ese componente funciona pegado tal cual. Tres detalles del idioma de Solid importan.

La referencia se declara como una variable local con let y se pasa con ref. Solid la asigna antes de que corra onMount, así que dentro del callback ya es un elemento real.

onCleanup se puede registrar en cualquier punto del cuerpo del componente, no solo dentro de un efecto, y se ejecuta al desmontar. Como ctx es una variable del ámbito del componente, cada instancia tiene la suya.

El ámbito de selectores es el segundo argumento de gsap.context(), exactamente igual que en cualquier otro sitio. Es lo que hace que veinte tarjetas no se pisen.

Para animaciones que dependen de estado reactivo, el equivalente de las dependencias es createEffect, que se resuscribe solo a lo que lea:

import { createEffect, onCleanup } from "solid-js";

createEffect(() => {
  const abierto = props.abierto;   // dependencia detectada sola
  gsap.to(".panel", { height: abierto ? "auto" : 0, duration: 0.4 });
});

createEffect no necesita array de dependencias porque Solid detecta las lecturas en tiempo de ejecución. Y si dentro creas instancias que deben reemplazar a las anteriores, se registra un onCleanup dentro del efecto, que Solid ejecuta antes de cada reejecución y al desmontar.

createEffect(() => {
  const modo = props.modo;
  const st = ScrollTrigger.create({ trigger: contenedor, scrub: modo === "scrub" ? 1 : false });
  onCleanup(() => st.kill());
});

Astro: no hay componente en el cliente

Aquí cambia el modelo por completo, y confundirlo con el de React es la causa de la mayoría de los problemas.

Un componente de Astro se renderiza en el servidor y no existe en el cliente. Lo que sí llega al navegador es el contenido de sus etiquetas script, que Astro empaqueta como módulos, deduplica y ejecuta una sola vez. No hay montaje, no hay desmontaje, y si el mismo componente aparece cinco veces en la página, su script se ejecuta una vez, no cinco.

Eso tiene una consecuencia inmediata: no puedes usar selectores relativos al componente desde su script, porque el script no sabe cuál de las cinco copias es la suya. Hay que recorrerlas todas.

---
// src/components/TarjetaAnimada.astro
---
<article class="tarjeta-animada">
  <h3 class="titulo"><slot /></h3>
</article>

<script>
  import { gsap } from "gsap";
  import { ScrollTrigger } from "gsap/ScrollTrigger";

  gsap.registerPlugin(ScrollTrigger);

  function inicializar() {
    document.querySelectorAll(".tarjeta-animada").forEach((tarjeta) => {
      gsap.context(() => {
        gsap.from(".titulo", {
          autoAlpha: 0,
          y: 24,
          duration: 0.6,
          scrollTrigger: { trigger: tarjeta, start: "top 80%", once: true },
        });
      }, tarjeta);
    });
  }

  inicializar();
</script>

El bucle sobre todas las instancias, con un contexto de ámbito por cada una, es el equivalente astro del scope de React. Cada tarjeta anima lo suyo aunque el código se ejecute una sola vez.

ℹ️
Los scripts empaquetados solo se ejecutan una vez

La documentación de Astro es explícita: los scripts de módulo empaquetados, que son los que obtienes por defecto, se ejecutan una única vez y se ignoran en navegaciones posteriores aunque el script exista en la página nueva. Los scripts en línea sin procesar sí pueden reejecutarse. Ese detalle es la raíz del comportamiento de la sección siguiente.

Astro con navegación del lado del cliente

Si añades el componente de router de Astro a tu layout, la navegación deja de recargar la página: se sustituye el cuerpo del documento y se conserva el estado de JavaScript. Con eso aparecen dos problemas a la vez.

El primero es que tu código de inicialización, al estar en un módulo empaquetado, no se vuelve a ejecutar en la ruta nueva. Las tarjetas de la página nueva se quedan sin animar.

El segundo, peor, es que las instancias de ScrollTrigger de la ruta anterior siguen vivas, apuntando a elementos que ya no están en el documento y con intervalos calculados para una altura que ya no existe.

Los dos se resuelven con los eventos del ciclo de vida del router. astro:page-load se dispara al final de cada navegación, incluida la carga inicial, y es el sitio de la inicialización. astro:before-swap se dispara justo antes de que se reemplace el documento, y es el sitio de la limpieza.

<script>
  import { gsap } from "gsap";
  import { ScrollTrigger } from "gsap/ScrollTrigger";

  gsap.registerPlugin(ScrollTrigger);

  let contextos = [];

  function inicializar() {
    contextos = [...document.querySelectorAll(".tarjeta-animada")].map((tarjeta) =>
      gsap.context(() => {
        gsap.from(".titulo", {
          autoAlpha: 0,
          y: 24,
          duration: 0.6,
          scrollTrigger: { trigger: tarjeta, start: "top 80%", once: true },
        });
      }, tarjeta)
    );
  }

  function limpiar() {
    contextos.forEach((ctx) => ctx.revert());
    contextos = [];
  }

  document.addEventListener("astro:page-load", inicializar);
  document.addEventListener("astro:before-swap", limpiar);
</script>

Ese bloque funciona pegado tal cual y es el patrón completo. astro:page-load cubre también la primera carga, así que no hace falta llamar a inicializar() a mano.

Si además tienes animaciones globales que dependen de la altura del documento, conviene refrescar después del intercambio, porque la página nueva tiene otra altura:

document.addEventListener("astro:page-load", () => {
  inicializar();
  ScrollTrigger.refresh();
});

Y hay un caso que merece mención aparte: los elementos marcados para persistir entre navegaciones. Si un elemento sobrevive al intercambio, las animaciones que lo afecten no deben revertirse en astro:before-swap, o el elemento persistente perderá su estado visual. Ese es el único caso en que conviene separar la limpieza en dos grupos.

Islas de framework dentro de Astro

Cuando el componente que anima es una isla —un componente de React o de Solid con directiva de cliente—, el modelo vuelve a ser el del framework: hay montaje y desmontaje reales, y se aplica todo lo de las secciones anteriores. useGSAP() funciona con normalidad en una isla de React, y onMount con onCleanup en una de Solid.

Lo que cambia es cuándo ocurre el montaje, y eso importa mucho para las animaciones de scroll.

client:load hidrata en cuanto carga la página. client:idle espera a que el hilo principal esté libre. client:visible espera a que el componente entre en el viewport. client:only no renderiza nada en el servidor.

client:visible es especialmente traicionera con ScrollTrigger: la isla se hidrata cuando ya está en pantalla, así que una animación de entrada con start: "top 80%" se crea con la instancia ya pasada de su punto de disparo, y no se ve nunca. Si la animación es un revelado al entrar en pantalla, la directiva correcta suele ser client:load o client:idle, o bien aceptar que la isla se hidrate visible y usar toggleActions con reproducción inmediata.

Y en todos los casos, la hidratación de islas cambia la altura del documento, así que un ScrollTrigger.refresh() después de que todas hayan cargado evita el desajuste clásico de “las animaciones se disparan un poco tarde”.

Los tres modelos son el mismo problema con tres respuestas a la pregunta de cuándo existe tu código

Merece la pena ver los tres casos juntos, porque revelan que la dificultad de integrar animaciones en un framework nunca está en el framework sino en una sola pregunta: ¿en qué instantes tu código tiene la oportunidad de correr, y qué garantiza el sistema sobre el DOM en cada uno de esos instantes? React responde con un ciclo de vida explícito por componente y con la advertencia de que puede ejecutarlo varias veces, así que necesitas idempotencia y limpieza; de ahí sale useGSAP(). Solid responde con un ciclo de vida que corre exactamente una vez y con efectos que se resuscriben solos, así que solo necesitas emparejar creación con onCleanup; por eso no hace falta un hook dedicado y el framework ya trae la respuesta. Astro responde diciendo que, sin router de cliente, tu script corre una vez sobre un documento estático que no volverá a cambiar; y con router de cliente, que corre una vez pero el documento se reemplazará bajo tus pies varias veces, de modo que la unidad de vida deja de ser el componente y pasa a ser la navegación, con sus propios eventos de apertura y cierre. Las tres respuestas son distintas y las tres exigen exactamente lo mismo de ti: identificar el par de instantes que delimitan la vida de tus animaciones y atar la creación al primero y la reversión al segundo. Cuando llegue el próximo framework —y llegará— no tendrás que aprender un patrón nuevo. Tendrás que hacer una sola pregunta, la de cuál es su par de instantes, y el resto de esta lección se traduce solo. Esa es la razón de que gsap.context() no sea específico de ninguno: es deliberadamente agnóstico respecto al cuándo, y deja que el framework aporte los dos momentos.

⚔️ Traducir el patrón
  1. Monta el componente de Solid del ejemplo con cinco instancias y comprueba que cada una anima lo suyo gracias al ámbito del contexto.
  2. Quita el onCleanup y navega entrando y saliendo diez veces, contando instancias de ScrollTrigger.
  3. En Astro sin router de cliente, comprueba que el script de un componente repetido cinco veces se ejecuta una sola vez.
  4. Añade el router de cliente, navega entre dos rutas y verifica que sin astro:page-load la página nueva no anima.
  5. Pon una isla con client:visible que tenga un revelado por scroll y comprueba si llega a verse. Cambia la directiva y compara.