Race conditions

Race conditions в контексте Popmotion возникают тогда, когда несколько анимационных процессов одновременно изменяют одно и то же состояние, и итоговое значение зависит не от логики программы, а от порядка завершения этих процессов. В результате поведение становится недетерминированным: одинаковые действия могут приводить к разным результатам в зависимости от таймингов кадров, задержек и прерываний.

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


Конкуренция анимаций за одно состояние

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

Типичный сценарий: свойство x объекта изменяется двумя независимыми tween-анимациями.

import { animate } from "popmotion";

const state = { x: 0 };

animate({
  from: 0,
  to: 300,
  duration: 1000,
  onUpdate: v => state.x = v
});

animate({
  from: 0,
  to: 600,
  duration: 500,
  onUpdate: v => state.x = v
});

Обе анимации записывают значение в state.x, но завершение происходит в неопределённом порядке. В итоге итоговое значение зависит от того, какая анимация обновила состояние последней.


Перекрытие анимаций и эффект «перезаписи»

Race condition усиливается, когда анимации запускаются в ответ на события интерфейса: hover, drag, click.

Каждое новое событие создаёт новую анимацию, не отменяя предыдущую:

element.addEventListener("mouseenter", () => {
  animate({
    from: 0,
    to: 1,
    duration: 300,
    onUpdate: v => element.style.opacity = v
  });
});

element.addEventListener("mouseleave", () => {
  animate({
    from: 1,
    to: 0,
    duration: 300,
    onUpdate: v => element.style.opacity = v
  });
});

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


Отсутствие отмены как источник неопределённости

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

const controls = animate({
  from: 0,
  to: 100,
  onUpdate: v => state.x = v
});

// позже
controls.stop();

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


Конфликт «последнего обновления»

В Popmotion нет встроенного механизма приоритизации анимаций. Поэтому применяется стратегия «last write wins»: последнее записанное значение становится текущим.

Это становится проблемой, когда:

  • новая анимация стартует до завершения старой
  • старая анимация завершается позже новой
  • несколько источников обновляют одно значение

В таких случаях порядок кадров определяет итог, а не бизнес-логика.


Переинициализация состояния без синхронизации

Частый паттерн — запуск анимации от текущего значения, полученного асинхронно:

let x = 0;

function moveTo(value) {
  animate({
    from: x,
    to: value,
    duration: 500,
    onUpdate: v => x = v
  });
}

Если moveTo вызывается несколько раз подряд, каждая новая анимация использует устаревшее значение x, потому что предыдущие анимации ещё не завершили обновление. Это создаёт эффект «прыжков» и пересечения траекторий.


Состояние как единственный источник истины

Устранение race conditions требует централизованного управления состоянием анимации. Вместо множества независимых анимаций используется одна активная сущность управления.

let currentAnimation = null;

function animateX(to) {
  if (currentAnimation) {
    currentAnimation.stop();
  }

  currentAnimation = animate({
    from: state.x,
    to,
    onUpdate: v => state.x = v,
    onComplete: () => {
      currentAnimation = null;
    }
  });
}

Здесь гарантируется, что одновременно существует только одна анимация, управляющая state.x.


Инвалидация устаревших анимаций

Другой подход — маркировка анимаций идентификаторами, позволяющая игнорировать устаревшие обновления.

let animationId = 0;

function animateX(to) {
  const id = ++animationId;

  animate({
    from: state.x,
    to,
    onUpdate: v => {
      if (id !== animationId) return;
      state.x = v;
    }
  });
}

Даже если старая анимация продолжает выполняться, её обновления становятся неактуальными и не влияют на состояние.


Race conditions при pointer-интеракциях

В Popmotion часто используется pointer для drag-and-drop логики. Здесь race conditions проявляются при одновременной работе pointer-слежения и инерционных анимаций.

Типичный конфликт:

  • pointer обновляет позицию напрямую
  • spring-анимация продолжает «дотягивать» объект до цели
pointer({
  onMove: ({ x, y }) => {
    state.x = x;
    state.y = y;
  }
});

animate({
  from: state.x,
  to: 300,
  type: "spring",
  onUpdate: v => state.x = v
});

В результате оба процесса одновременно управляют state.x, создавая дрожание или эффект борьбы за контроль.


Разделение каналов управления

Для устранения конфликтов состояние делится на независимые каналы:

  • pointer управляет rawX, rawY
  • анимация управляет animatedX, animatedY
  • итоговое значение вычисляется отдельно
let rawX = 0;
let animatedX = 0;

pointer({
  onMove: ({ x }) => rawX = x
});

animate({
  from: 0,
  to: 1,
  onUpdate: v => animatedX = v
});

Композиция значений выполняется на этапе рендера, а не внутри анимаций.


Прерывание цепочек анимаций

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

function sequence() {
  animate({
    from: 0,
    to: 100,
    onComplete: () => {
      animate({
        from: 100,
        to: 200
      });
    }
  });
}

Если sequence() вызывается несколько раз подряд, вложенные onComplete создают пересекающиеся цепочки, которые продолжают исполняться параллельно.


Защита через отмену зависимых процессов

Цепочки требуют явной отмены всех предыдущих шагов:

let activeControls = [];

function startSequence() {
  activeControls.forEach(c => c.stop());
  activeControls = [];

  const a = animate({
    from: 0,
    to: 100,
    onComplete: () => {
      const b = animate({
        from: 100,
        to: 200
      });

      activeControls.push(b);
    }
  });

  activeControls.push(a);
}

Здесь гарантируется, что старые цепочки не продолжают выполнение.


Непредсказуемость кадрового планировщика

Даже при корректной логике остановки race conditions могут возникать из-за особенностей планирования requestAnimationFrame. Разные анимации могут:

  • стартовать в одном кадре
  • завершаться в соседних кадрах
  • иметь разные задержки обновлений из-за нагрузки

Это приводит к тому, что «логически завершённая» анимация ещё выполняет onUpdate в следующем кадре.


Идемпотентные обновления как защита

Обновления состояния становятся устойчивыми к race conditions, если они зависят не от времени, а от входного значения.

function setX(value) {
  state.x = value;
}

И анимации становятся источником данных, а не владельцем состояния.


Централизованная диспетчеризация анимаций

Наиболее устойчивый подход — наличие слоя диспетчера, который:

  • хранит активные анимации
  • отменяет устаревшие
  • гарантирует единственность источника для каждого свойства
const animations = new Map();

function runAnimation(key, config) {
  if (animations.has(key)) {
    animations.get(key).stop();
  }

  const controls = animate(config);
  animations.set(key, controls);

  controls.onCompl ete = () => {
    if (animations.get(key) === controls) {
      animations.delete(key);
    }
  };

  return controls;
}

Комбинирование spring и tween без конфликтов

При смешивании физически основанных анимаций (spring) и временных (tween) race conditions проявляются особенно часто, поскольку spring продолжает корректировать значение после завершения tween.

Решение заключается в разделении целей:

  • tween задаёт target
  • spring стремится к target
  • прямых конкурирующих onUpdate нет

Итоговая структура устойчивого подхода

Устойчивые анимационные системы в Popmotion опираются на несколько принципов:

  • единственный активный контроллер на свойство
  • отмена предыдущих анимаций перед запуском новых
  • игнорирование устаревших обновлений через идентификаторы
  • разделение raw input и animated state
  • централизованное управление жизненным циклом анимаций