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;
}
});
}
Даже если старая анимация продолжает выполняться, её обновления становятся неактуальными и не влияют на состояние.
В Popmotion часто используется pointer для drag-and-drop
логики. Здесь race conditions проявляются при одновременной работе
pointer-слежения и инерционных анимаций.
Типичный конфликт:
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, создавая дрожание или эффект борьбы за
контроль.
Для устранения конфликтов состояние делится на независимые каналы:
rawX, rawYanimatedX,
animatedYlet 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) race conditions проявляются особенно
часто, поскольку spring продолжает корректировать значение после
завершения tween.
Решение заключается в разделении целей:
onUpdate нетУстойчивые анимационные системы в Popmotion опираются на несколько принципов: