wandres.dev
GSAP VII · ScrollTrigger: pin y scrub

refresh, invalidateOnRefresh y el ciclo de remedición

Qué ocurre exactamente durante un refresh, por qué el resize lo exige, cómo se ordenan las instancias, y qué papel juegan saveStyles e invalidateOnRefresh.

⏱ 20 min

Casi todos los fallos de ScrollTrigger que sobreviven a la fase de desarrollo son fallos de remedición. En el portátil de quien lo escribió la ventana nunca cambia de tamaño, las fuentes están en caché y las imágenes cargan al instante; en producción el usuario gira el móvil, la barra de direcciones se esconde, una fuente tarda 300 milisegundos y una imagen sin dimensiones empuja media página. El mecanismo que existe para eso es refresh(), y entender su secuencia interna —qué se deshace, en qué orden se mide y qué se vuelve a aplicar— es lo que separa una integración que aguanta de una que hay que vigilar.

🎯 Al terminar esta lección sabrás
  • Describir la secuencia de un refresh() y qué hace con las fijaciones.
  • Enumerar los eventos que disparan un refresco automático y los que no.
  • Usar invalidateOnRefresh y saveStyles para que las animaciones sobrevivan a la remedición.
  • Controlar el orden de refresco con refreshPriority y ScrollTrigger.sort().

Qué pasa durante un refresh

Un refresh() no es solo “volver a medir”. Es una secuencia con un orden estricto, y conocerlo explica todos sus efectos secundarios.

Primero se dispara el evento global "refreshInit", antes de que nada se toque. Es el único momento en que el DOM está en su estado actual y todavía nadie ha empezado a deshacer.

Después viene el revert: todas las fijaciones se deshacen, los espaciadores se retiran del DOM y los elementos fijados vuelven a su sitio. Las animaciones asociadas también se revierten a su estado de partida. El objetivo es dejar el documento exactamente como estaría si ScrollTrigger no existiese, porque cualquier residuo falsearía la medición.

Con el documento limpio se miden todas las instancias, en orden. Cada una calcula su start y su end en píxeles, y cada instancia que fija añade su distancia al total, de modo que las siguientes ya miden sobre el documento alargado.

Por último se reaplica todo: los espaciadores vuelven, las fijaciones activas se reactivan, los progresos se recalculan y las animaciones se colocan donde toca según la nueva posición.

Ese orden es la explicación del requisito que más gente incumple: las instancias deben crearse en el orden en que aparecen en la página. La medición recorre la lista en orden de creación, y si una instancia que fija se mide después de otra que está más abajo, la de abajo ya calculó sus posiciones sin contar con el alargamiento.

// Ver el orden real de refresco de tus instancias
ScrollTrigger.getAll().forEach((t, i) => {
  console.log(i, t.vars.id ?? "sin-id", Math.round(t.start), t.vars.pin ? "FIJA" : "");
});

Cuando no puedes controlar el orden de creación —porque las instancias nacen en componentes independientes que se montan cuando les toca— hay dos remedios. refreshPriority acepta un número: cuanto más alto, antes se refresca. Y ScrollTrigger.sort() reordena la lista interna entera, con una función de comparación propia si se la pasas.

Qué dispara un refresco y qué no

El refresco automático ocurre cuando el scroller cambia de tamaño, y no de inmediato: ScrollTrigger espera a que haya un hueco de unos doscientos milisegundos sin eventos de resize antes de ponerse a trabajar, porque medir es caro y durante un arrastre de ventana llegan decenas de eventos por segundo.

Lo que no dispara refresco es todo lo demás, y la lista es larga:

Una imagen sin width y height declarados que termina de cargar y empuja el contenido. Una fuente web que llega tarde y cambia la altura de un bloque de texto. Un acordeón que se abre. Contenido cargado por fetch e insertado en el DOM. Un componente de un framework que se monta después. Un cambio de idioma que altera la longitud de todos los textos. La aparición o desaparición de una barra de scroll.

// Los tres casos que hay que cubrir siempre
window.addEventListener("load", () => ScrollTrigger.refresh());

document.fonts.ready.then(() => ScrollTrigger.refresh());

const obs = new ResizeObserver(() => ScrollTrigger.refresh());
obs.observe(document.querySelector(".contenido-dinamico"));

El ResizeObserver es la herramienta correcta para contenido que cambia de altura por razones que no son el resize de la ventana. Conviene no observar el body entero, porque cualquier fijación cambia su altura y provocarías un bucle de refrescos.

⚠️
Un refresh no es gratis

Cada refresco deshace todas las fijaciones, mide todo el documento y lo vuelve a montar. En una página con veinte instancias y tres fijaciones eso son varios milisegundos de trabajo síncrono en el hilo principal, con lecturas de geometría que fuerzan recálculo de layout. Llamarlo en un onUpdate, en un scroll o dentro de un bucle es la forma más rápida de convertir una página fluida en una presentación de diapositivas. Llámalo cuando el documento haya cambiado, no por si acaso.

invalidateOnRefresh y saveStyles

Hay un caso en que el refresco no basta: cuando la animación memorizó valores de partida que también han cambiado.

GSAP guarda los valores iniciales de un tween la primera vez que se renderiza, porque volver a leerlos del DOM en cada frame sería carísimo. Si tienes gsap.to(".panel", { x: -1200 }) calculado para una ventana de 1440 píxeles y el usuario redimensiona a 800, el refresco recalcula el intervalo de scroll pero el tween sigue llevando el panel a menos mil doscientos.

invalidateOnRefresh: true llama a invalidate() sobre la animación en cada refresco, lo que descarta esos valores memorizados y obliga a releerlos. Es imprescindible siempre que los valores animados dependan de dimensiones.

gsap.to(".carril", {
  x: () => -(document.querySelector(".carril").scrollWidth - window.innerWidth),
  ease: "none",
  scrollTrigger: {
    trigger: ".escena",
    pin: true,
    scrub: 1,
    invalidateOnRefresh: true,
    end: () => "+=" + document.querySelector(".carril").scrollWidth,
  },
});

El valor de x es una función precisamente porque invalidateOnRefresh la volverá a llamar. Un valor literal no se recalcularía aunque invalidases.

ScrollTrigger.saveStyles() resuelve el problema hermano. Durante el revert, ScrollTrigger elimina los estilos en línea que sus animaciones habían aplicado. Si tú habías puesto estilos en línea propios sobre esos mismos elementos —o si una animación de otra media query los había dejado— se pierden. saveStyles() toma una instantánea de los estilos en línea actuales y le dice a ScrollTrigger que restaure exactamente esos al revertir, descartando el resto.

ScrollTrigger.saveStyles(".panel, .titular");

Es especialmente necesario en combinación con animaciones condicionales por media query, donde al cambiar de condición hay que dejar los elementos como estaban antes de que la condición anterior los tocase.

matchMedia: lo que se hacía antes y lo que se hace ahora

Hubo una época en que las animaciones de scroll condicionales se escribían con ScrollTrigger.matchMedia(). Esa API está marcada como obsoleta en la documentación oficial. Sigue existiendo, pero lo que debes escribir hoy es gsap.matchMedia(), que es más general —funciona con cualquier animación, no solo con las de scroll—, integra la limpieza automática de gsap.context() y permite condiciones con nombre.

const mm = gsap.matchMedia();

mm.add("(min-width: 900px)", () => {
  // Solo se crea en escritorio. Al dejar de cumplirse la condicion,
  // esta instancia se revierte y se destruye sola.
  ScrollTrigger.create({
    trigger: ".escena",
    pin: true,
    scrub: 1,
    end: "+=2000",
  });
});

El mecanismo completo, con condiciones con nombre, funciones de limpieza y la relación con gsap.context(), está en la lección dedicada a matchMedia.

El refresh es una invalidación de caché, y sufre las dos dificultades clásicas de las invalidaciones de caché

Hay una frase muy citada sobre las dos cosas difíciles en informática, y ScrollTrigger es un ejemplo de manual de la primera. Las posiciones calculadas son una caché: un resultado derivado de un cálculo caro sobre unas entradas que pueden cambiar. Y como toda caché, tiene los dos problemas de siempre. El primero es saber cuándo invalidar, y aquí es genuinamente difícil porque las entradas del cálculo son el layout completo del documento, es decir, prácticamente todo: cualquier píxel de altura que cambie en cualquier sitio invalida las posiciones de todo lo que esté debajo. El navegador no ofrece ningún evento que signifique “el layout ha cambiado”, porque tal evento se dispararía constantemente y sería inútil. Así que ScrollTrigger se apoya en la única señal barata y fiable que existe —el resize del scroller— y delega el resto en ti. No es pereza de diseño: es reconocer que el conocimiento de cuándo tu documento cambia de altura lo tienes tú y no puede tenerlo una biblioteca. El segundo problema es que la invalidación no es idempotente respecto al estado visible: para poder medir de nuevo hay que deshacer lo que ya se aplicó, y deshacer significa quitar del DOM el espaciador, devolver el elemento a su sitio y revertir las animaciones. Durante esos pocos milisegundos tu documento tiene una forma que ningún usuario debería ver, y de ahí sale el destello ocasional al girar un móvil. Toda la superficie de API que rodea al refresco —invalidateOnRefresh, saveStyles, refreshPriority, sort, refreshInit— existe para gestionar esos dos problemas, y una vez que los ves como lo que son, la API deja de parecer un montón de opciones sueltas y se lee como lo que es: el juego completo de mandos de una invalidación de caché que no puede ser automática.

⚔️ Provocar y arreglar la desincronización
  1. Quita los atributos width y height de una imagen grande por encima de una animación de scroll y observa el desfase con la caché desactivada.
  2. Añade el refresco en load y en document.fonts.ready y comprueba que desaparece.
  3. Monta un carril con x calculado en función del ancho y redimensiona sin invalidateOnRefresh. Añádelo y compara.
  4. Crea dos instancias en orden inverso al de la página, una con pin, y arréglalo primero reordenando y luego con refreshPriority.
  5. Pon un ResizeObserver sobre un acordeón que dispare refresh() y comprueba que el intervalo se recalcula al abrirlo.