Некорректная отмена

В системах анимации Popmotion управление жизненным циклом анимаций строится вокруг объектов действий (actions), которые могут запускаться, подписываться и завершаться. Одним из ключевых механизмов является возможность отмены выполнения. Однако при неправильной организации отмены возникают состояния гонки, утечки подписок, «залипшие» анимации и неконсистентные значения.

Базовый механизм отмены

Любое действие в Popmotion возвращает объект с методами управления:

  • start
  • stop
  • subscribe

При вызове stop() действие прекращает эмиссию значений. На первый взгляд этого достаточно, чтобы гарантировать завершение анимации.

import { tween } from 'popmotion';

const animation = tween({
  from: 0,
  to: 100,
  duration: 1000
}).start({
  update: v => console.log(v),
  complete: () => console.log('done')
});

animation.stop();

В этом случае анимация прекращается, но внутренние эффекты могут остаться незавершёнными, если подписчики или композиции действий устроены сложнее.

Проблема повторного запуска без очистки

Наиболее частая ошибка возникает при повторном создании анимации без остановки предыдущей.

let currentAnimation;

function startAnimation() {
  currentAnimation = tween({
    from: 0,
    to: 200,
    duration: 800
  }).start(v => {
    console.log(v);
  });
}

Если startAnimation вызывается несколько раз подряд, предыдущие экземпляры продолжают существовать, если не были явно остановлены.

Корректный вариант требует явного контроля:

let currentAnimation;

function startAnimation() {
  if (currentAnimation) {
    currentAnimation.stop();
  }

  currentAnimation = tween({
    from: 0,
    to: 200,
    duration: 800
  }).start(v => {
    console.log(v);
  });
}

Однако даже в этом случае возможна логическая ошибка: stop() не всегда гарантирует прекращение всех побочных эффектов внутри сложных цепочек.

Некорректная отмена в цепочках transform/pipe

Popmotion позволяет строить цепочки трансформаций через pipe. В таких случаях отмена внешнего действия не всегда останавливает внутренние подписки, если они были созданы отдельно.

import { tween, pipe, clamp } from 'popmotion';

const transform = pipe(
  v => v * 2,
  clamp(0, 100)
);

const animation = tween({
  from: 0,
  to: 50,
  duration: 1000
}).start(v => {
  const result = transform(v);
  console.log(result);
});

Если остановить animation, но трансформация используется в другом активном контексте, она продолжит работать логически, хотя поток данных остановлен. Ошибка здесь заключается в смешивании чистых функций и активных подписок.

Потеря ссылки на действие

Распространённая проблема возникает при асинхронном запуске анимаций, когда ссылка на действие теряется.

function animateBox() {
  tween({
    from: 0,
    to: 300,
    duration: 1200
  }).start(v => {
    box.style.transform = `translateX(${v}px)`;
  });
}

Отмена в таком случае невозможна, потому что объект действия не сохранён. При повторных вызовах создаются независимые анимации, которые невозможно остановить извне.

Исправленный подход:

let boxAnimation;

function animateBox() {
  if (boxAnimation) {
    boxAnimation.stop();
  }

  boxAnimation = tween({
    from: 0,
    to: 300,
    duration: 1200
  }).start(v => {
    box.style.transform = `translateX(${v}px)`;
  });
}

Некорректная отмена при использовании chain

При использовании chain несколько действий выполняются последовательно. Ошибка возникает, когда останавливается только первый элемент цепочки.

import { chain, tween } from 'popmotion';

const sequence = chain(
  tween({ from: 0, to: 100, duration: 500 }),
  tween({ from: 100, to: 200, duration: 500 })
);

const action = sequence.start(v => {
  console.log(v);
});

Вызов:

action.stop();

останавливает текущий элемент цепи, но следующий может уже быть запущен или подготовлен к запуску в рамках внутреннего планировщика. Это приводит к ситуации, когда часть цепочки продолжает исполняться.

Корректное управление требует внешнего флага состояния:

let isStopped = false;

const sequence = chain(
  tween({ from: 0, to: 100, duration: 500 }),
  tween({ from: 100, to: 200, duration: 500 })
);

const action = sequence.start(v => {
  if (isStopped) return;
  console.log(v);
});

function cancel() {
  isStopped = true;
  action.stop();
}

Отмена внутри spring и инерционных анимаций

Инерционные анимации создают дополнительную сложность, поскольку их завершение зависит от внутренних условий, а не фиксированного времени.

import { spring } from 'popmotion';

const animation = spring({
  from: 0,
  to: 100,
  stiffness: 100
}).start(v => {
  console.log(v);
});

При вызове stop() анимация прекращается, но последнее значение может быть уже вычислено и отправлено в update на следующем кадре. Это создаёт эффект «последнего лишнего кадра».

Конкурирующие отмены

При параллельном запуске нескольких анимаций над одним объектом возникает состояние гонки отмен:

let animationA;
let animationB;

function runA() {
  animationA = tween({ from: 0, to: 100, duration: 500 }).start(v => {
    box.style.left = v + 'px';
  });
}

function runB() {
  animationB = tween({ from: 100, to: 0, duration: 500 }).start(v => {
    box.style.left = v + 'px';
  });
}

Если runA и runB вызываются быстро друг за другом, остановка одной анимации не гарантирует, что другая не перезапишет результат в следующем кадре. В результате DOM получает значения от уже «устаревшей» анимации.

Решение заключается в введении идентификатора актуальности:

let currentId = 0;

function runA() {
  const id = ++currentId;

  tween({ from: 0, to: 100, duration: 500 }).start(v => {
    if (id !== currentId) return;
    box.style.left = v + 'px';
  });
}

function runB() {
  const id = ++currentId;

  tween({ from: 100, to: 0, duration: 500 }).start(v => {
    if (id !== currentId) return;
    box.style.left = v + 'px';
  });
}

Утечки подписчиков при ручном subscribe

При использовании низкоуровневого API subscribe отмена требует явного удаления подписки. Ошибка возникает, когда разработчик вызывает stop(), но оставляет активные подписчики.

const animation = tween({
  from: 0,
  to: 100,
  duration: 1000
});

const subscription = animation.subscribe(v => {
  console.log(v);
});

animation.stop();

Подписка остаётся активной и может получать финальные или промежуточные значения в зависимости от внутренней реализации планировщика. Правильный подход требует явного отписывания:

subscription.unsubscribe();
animation.stop();

Некорректная отмена при композиции действий

При создании сложных композиций через composite или пользовательские обёртки ошибка отмены часто возникает из-за того, что внутренние действия живут собственной жизнью.

function customAnimation() {
  const anim = tween({ from: 0, to: 100, duration: 1000 });

  return anim.start(v => {
    console.log(v);
  });
}

Если customAnimation возвращает только результат start, внешний код не имеет доступа к исходному действию, и корректная отмена невозможна.

Правильная архитектура требует возврата управляющего объекта:

function customAnimation() {
  const anim = tween({ from: 0, to: 100, duration: 1000 });

  const controller = anim.start(v => {
    console.log(v);
  });

  return {
    stop: () => controller.stop()
  };
}

Потеря синхронизации состояния после отмены

Даже при корректной остановке анимации состояние может оставаться в промежуточном значении. Это создаёт иллюзию «некорректной отмены».

const animation = tween({
  from: 0,
  to: 100,
  duration: 1000
}).start(v => {
  box.style.width = v + 'px';
});

animation.stop();

После остановки ширина остаётся на последнем значении, так как Popmotion не откатывает состояние автоматически. Это не ошибка библиотеки, а следствие императивного характера обновления DOM.

Для предсказуемого поведения требуется ручной возврат:

animation.stop();
box.style.width = '0px';