wandres.dev
WAAPI II · El objeto Animation

Las dos promesas: finished y ready

Qué garantiza cada una, por qué se reemplazan cuando la animación cambia de estado, y el rechazo silencioso que llena la consola de errores no capturados.

⏱ 17 min

Animation expone dos promesas con propósitos distintos. ready responde a “¿ya está de verdad en marcha?” y se cumple cuando el navegador ha terminado de negociar el arranque con el compositor. finished responde a “¿ha terminado?” y se cumple al alcanzar el tiempo final. Las dos tienen una propiedad que las separa de cualquier promesa normal: se reemplazan. Guardar una referencia y esperarla más tarde es una de las formas más sutiles de escribir código que funciona una vez y falla la segunda.

🎯 Al terminar esta lección sabrás
  • Usar ready para leer el tiempo de inicio real de una animación.
  • Encadenar animaciones con finished y async/await.
  • Explicar en qué momento se sustituyen ambas promesas por otras nuevas.
  • Capturar el AbortError que produce cancel() y evitar el rechazo no gestionado.

ready: cuando el arranque se ha resuelto

Crear una animación no la pone en marcha instantáneamente. Durante uno o dos frames queda pendiente: anim.pending vale true y anim.startTime vale null, porque el instante exacto de inicio se acuerda con el proceso de composición.

const anim = el.animate(kf, 1000);
console.log(anim.startTime);       // null
await anim.ready;
console.log(anim.startTime);       // un numero, en la escala de document.timeline

ready se cumple con la propia animación como valor, igual que finished, así que se puede encadenar. Sus usos concretos son tres: leer el startTime real para sincronizar otras cosas con él; saber cuándo un pause() ha surtido efecto de verdad; y confirmar que un updatePlaybackRate() ha entrado en vigor.

async function arrancarAlUnisono(elementos, keyframes, opciones) {
  const animaciones = elementos.map((el) => el.animate(keyframes, opciones));
  await Promise.all(animaciones.map((a) => a.ready));
  const ancla = animaciones[0].startTime;
  for (const a of animaciones) a.startTime = ancla;
  return animaciones;
}

Sin el await, los startTime valdrían null y la asignación no serviría de nada. Es la razón por la que existe la promesa: hay un instante en el que ese valor no está disponible y necesitas una forma de esperarlo que no sea sondear en un bucle.

finished: cuando se alcanza el tiempo final

finished se cumple cuando la animación entra en estado 'finished', es decir, cuando su tiempo actual alcanza el tiempo final —que incluye el retardo final— con velocidad positiva, o el cero con velocidad negativa.

async function entrarYSalir(el) {
  await el.animate({ opacity: [0, 1], transform: ['scale(0.9)', 'scale(1)'] },
    { duration: 240, easing: 'ease-out', fill: 'both' }).finished;

  await new Promise((r) => setTimeout(r, 1500));

  await el.animate({ opacity: [1, 0] },
    { duration: 200, easing: 'ease-in', fill: 'both' }).finished;

  el.remove();
}

Esa es la forma canónica de secuenciar, y es muchísimo mejor que escuchar animationend sobre el elemento por dos motivos. El primero es que animationend burbujea: una animación de un hijo dispara el evento en el padre y tu manejador se ejecuta para animaciones que no son la tuya. El segundo es que un await sobre finished no puede confundirse de animación, porque la promesa pertenece al objeto.

Con iterations: Infinity, finished no se cumple jamás. Un await sobre ella cuelga la función para siempre, sin error ni aviso. Es una causa habitual de código que “no sigue” y que no da ninguna pista en la consola.

El ciclo de vida de las dos promesas

Las dos se comportan de una forma que no tiene ninguna promesa normal, y las dos tienen un modo de fallo asociado.

Se reemplazan

Aquí está la parte que hay que entender bien. Ninguna de las dos promesas es una promesa fija asociada al objeto: son la promesa actual, y cambian.

Cuando una animación abandona el estado 'finished' —porque llamas a play(), porque asignas un currentTime anterior al final, o porque inviertes la velocidad— el motor descarta la promesa cumplida y crea una nueva, pendiente. Lo mismo ocurre con ready cada vez que se inicia una operación de reproducción o de pausa nueva.

const anim = el.animate(kf, 500);
const p1 = anim.finished;

await p1;                    // se cumple
anim.play();                 // rebobina: la animacion deja de estar finished
const p2 = anim.finished;

console.log(p1 === p2);      // false: son promesas distintas

Esto implica que guardar la promesa en una variable y esperarla más tarde es incorrecto si entre medias la animación puede reiniciarse. La forma segura es leer anim.finished en el momento de esperarla, no antes:

// MAL: captura la promesa de este ciclo concreto
const espera = anim.finished;
anim.play();
await espera;               // se cumple con el ciclo anterior, no con el actual

// BIEN: lee la promesa vigente
anim.play();
await anim.finished;

En la práctica el patrón await el.animate(...).finished es inmune al problema porque crea la animación y lee la promesa en la misma expresión. El riesgo aparece cuando reutilizas una animación de larga vida, que es exactamente el caso del panel que se abre y se cierra.

El rechazo que nadie captura

cancel() rechaza la promesa finished con un DOMException de nombre AbortError. Si nadie la había capturado, se produce un rechazo de promesa no gestionado, que en la consola aparece como un error rojo y en Node tumbaría el proceso.

const anim = el.animate(kf, 1000);
anim.finished.then(() => console.log('listo'));
anim.cancel();
// Uncaught (in promise) DOMException: The user aborted a request.

Esto ocurre constantemente en componentes que cancelan animaciones al desmontarse. Las dos formas de evitarlo:

// Con then/catch
anim.finished.then(() => hacerAlgo()).catch(() => {});

// Con async/await, distinguiendo el motivo
try {
  await anim.finished;
  hacerAlgo();
} catch (e) {
  if (e.name !== 'AbortError') throw e;
}

La segunda versión es la correcta en código de verdad, porque un catch vacío también se traga los errores reales. Y merece la pena tener una función auxiliar si el patrón se repite:

async function esperar(anim) {
  try { await anim.finished; return true; }
  catch (e) { if (e.name === 'AbortError') return false; throw e; }
}

Devuelve true si terminó y false si la cancelaron, que es justo la información que necesita el código que llama para decidir si continúa la secuencia o la aborta.

Nivel dios

Hay una asimetría entre las dos promesas que produce un bloqueo difícil de diagnosticar: ready no se rechaza al cancelar. Si cancelas una animación que todavía está pendiente de arrancar, finished se rechaza con AbortError como es de esperar, pero ready… se cumple. La especificación la resuelve, no la rechaza, porque su contrato es “las operaciones pendientes han terminado” y cancelar también termina con ellas. El resultado es que un await anim.ready seguido de código que asume que la animación está viva sigue ejecutándose sobre una animación en estado 'idle', con startTime a null y sin ningún efecto aplicado. El patrón de sincronización de grupos que enseñamos arriba falla exactamente así cuando una de las animaciones se cancela durante el Promise.all: no salta ninguna excepción, simplemente animaciones[0].startTime es null y todas quedan ancladas a null. La comprobación que hay que escribir después de cualquier await ready es if (anim.playState === 'idle') return;. Cuesta una línea y ahorra una tarde.

Combinando las dos

Un caso donde hacen falta las dos a la vez: una animación que debe empezar exactamente cuando otra alcance cierto punto, sin usar temporizadores.

async function encadenarSolapado(a, b, solape) {
  await a.ready;
  const finA = a.effect.getComputedTiming().endTime;
  b.pause();
  await b.ready;
  b.startTime = a.startTime + finA - solape;
  b.playbackRate = 1;
  b.play();
}

ready en las dos para tener anclas válidas, aritmética sobre startTime para colocar la segunda de modo que solape los últimos milisegundos de la primera, y ni un solo setTimeout. Si después alguien pausa la primera animación, la segunda no se entera y se desincroniza; para eso hace falta un sistema de coreografía más elaborado o una línea de tiempo compartida, que es otro asunto.

⚔️ Reto práctico

Escribe un componente que anime la entrada de un elemento con await anim.finished y que cancele la animación si el usuario cierra antes de tiempo. Comprueba que aparece el rechazo no gestionado en la consola. Añade el manejo del AbortError y verifica que desaparece. Después cancela la animación en el mismo tick en que se crea, antes de que ready se cumpla, y comprueba que el código posterior al await ready se ejecuta igualmente.