5.0 KiB
WAAPI Mid-Flight Interruption Research
The Bug
When using motion's useAnimate() and calling animate(element, { transform: newValue }) while a previous WAAPI animation is still running, there's a visible one-frame jump to origin (0,0).
Root Cause
Two issues in motion's useAnimate() path:
1. DOMKeyframesResolver is always async
DOMKeyframesResolver hardcodes isAsync = true in its constructor, deferring keyframe resolution to the next rAF frame even when no DOM measurement is needed. This creates a one-frame gap between cancelling the old WAAPI animation and starting the new one.
The animateMini path handles this correctly with a synchronous 3-step process (documented in animate-elements.ts:52): stop → read DOM → create animation. The full animate() path does not.
2. updateMotionValue() can't sample CSS transform strings
When NativeAnimationExtended.stop() is called, it runs updateMotionValue() which creates a renderless JSAnimation to estimate the mid-flight value. This doesn't work reliably for raw CSS transform strings like "translateX(100px) translateY(50px)". In testing, it produces ~191px jumps (essentially starting from origin).
Code Paths in Motion
| Path | Interruption Handling | Works? |
|---|---|---|
<motion.div animate={}> |
VisualElement render loop fills the async gap | Yes |
animateMini(element, ...) |
Sync 3-step: stop→commitStyles→read→animate | Yes |
animate(element, ...) via useAnimate() |
Async DOMKeyframesResolver + updateMotionValue estimation | No |
Key Files in Motion (~/github/motion)
packages/motion-dom/src/animation/keyframes/DOMKeyframesResolver.ts:34— hardcodedisAsync = truepackages/motion-dom/src/animation/keyframes/KeyframesResolver.ts:89—flushKeyframeResolvers()(exported but flushes ALL resolvers globally)packages/motion-dom/src/animation/NativeAnimationExtended.ts:62-93—updateMotionValue()with JSAnimation samplingpackages/motion-dom/src/animation/NativeAnimation.ts:150-184—stop()→updateMotionValue()orcommitStyles()packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts:198-205—.animationgetter triggersflushKeyframeResolvers()packages/framer-motion/src/animation/animators/waapi/animate-elements.ts:52-68— animateMini's 3-step process with explicit WAAPI interruption comment
Our Workaround (in createAnimations.tsx)
Part 1: Mid-flight capture
Manually call commitStyles() on WAAPI animations (via (controls as any).animations) to bake the current rendered value into inline style. Cancel the old animation, read back the committed transform, use it as first keyframe [committedTransform, newTarget].
Part 2: Persist on completion
After animation finishes, synchronously write final transform to node.style.transform. Prevents flash-back when WAAPI removes the finished animation layer.
Why it must reach into internals
commitStyles()is the only way to capture the actual WAAPI-rendered value without causing reflow (unlikegetComputedStyle)- Motion doesn't expose the raw WAAPI
Animationobjects through its public API - The
.animationsproperty onGroupAnimationWithThenis technically public but not typed
What We Tried (and Failed)
flushKeyframeResolvers() alone (no commitStyles)
Result: 191px jumps. Motion's updateMotionValue() JSAnimation estimation fails for CSS transform strings.
flushKeyframeResolvers() + commitStyles
Result: Still fails. Global flush resolves ALL pending resolvers, interfering with concurrent animations.
frame.render() for persist-on-completion
Result: Fails. Deferring the inline style write means if a new animation interrupts right after completion, the persisted transform isn't set yet.
void startedControls.state (triggers flush via getter)
Same as flushKeyframeResolvers() — triggers it via AsyncMotionValueAnimation.get animation() getter.
Suggested Fixes for Motion
updateMotionValue()should usecommitStyles()instead of JSAnimation estimation for WAAPI animations — the WAAPI animation already has the exact mid-flight valueDOMKeyframesResolvershould be sync when no measurement needed — if all keyframes are resolved (nonull, no CSS variables, no unit conversion), skip the async frame- Or: replicate
animateMini's 3-step process in the fullanimate()path for WAAPI interruptions
Available Public APIs (for future reference)
From motion/react:
frame— motion's rAF-based frameloop (frame.read,frame.render, etc.)flushKeyframeResolvers()— forces sync resolution of all pending resolverscancelFrame— cancels a scheduled frame callbackframeData/frameSteps— frameloop state and step references