motiondivision / motiondivision/motion
[BUG] AnimatePresence child re-entering during its exit stays stuck at initial (stale isExitComplete + blockInitialAnimation); MotionValue.start() orphans the next animation
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 33.7k
- Forks
- 1.4k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 14
Description
2. Describe the bug
A child of <AnimatePresence initial={false} mode="popLayout"> that re-enters while it is still exiting can end up permanently stuck at its initial values (opacity: 0; transform: scale(0)). We hit it in CI on a Playwright suite (our island/toolbar re-enters whenever a panel closes): the bar's content stayed at opacity: 0.457578; transform: scale(0) forever while the panel exited normally, so the next click timed out with "element is not visible". The DOM snapshots of the trace pinned it to the frame; a standalone harness and a jsdom test then reproduced it deterministically.
Two independent defects combine. Both are still present on main as of September 2026 (packages/motion-dom/src/value/index.ts#start, packages/framer-motion/src/motion/features/animation/exit.ts).
Defect A — ExitAnimationFeature.isExitComplete goes stale, then the "at rest" re-entry is blocked
- The exit promise can resolve after the element has already re-entered: the last exit animation finishes inside the
stop()thatstart()calls when the enter animation starts (JSAnimation.stop()performs a finaltick(time.now())), or its WAAPIonfinisharrives one frame later. With springs this is a one-frame window at the end of the exit (~600 ms withstiffness: 260, damping: 26) — exactly where a user closes a panel "once it has settled". The.thenthen setsisExitComplete = trueon an element that is present. - On the next re-entry — even mid-exit —
update()takes the "at rest" branch:jump(initial)(that's thescale(0)), thenanimationState.reset()+animateChanges(). ButanimateChanges()is a no-op for that element because(isInitialRender || wasReset) && visualElement.blockInitialAnimationblocks it: the element was the first child of anAnimatePresence initial={false}, soblockInitialAnimation === true. Nothing ever animates it back. Elements created later (blockInitialAnimation === false) replay their enter instead, which is why the bug depends on whether the very first child is still the mounted instance.
Defect B — MotionValue.start() deletes the reference to the next animation
start(B) calls stop() on A. If A finishes inside that stop() (last tick reaches its duration), A's onComplete resolves the promise created by the earlier start(A), whose .then(() => this.clearAnimation()) runs in a microtask — after this.animation = B was assigned. value.animation is now undefined while B runs: B is an orphan that value.stop() / value.start(C) can no longer stop, so it keeps writing until its final keyframe and wins against any later animation on the value (we observed the exit's orphan winning against the enter → scale(0)).
3. CodeSandbox reproduction
This link creates the sandbox on open (CodeSandbox "define" API; no account needed). It shows PASS/FAIL for both defects on motion@12.40.0:
Both checks are deterministic:
- Bug 1 uses
MotionGlobalConfig.instantAnimations = trueso the exit settles on the next frame — i.e. after two synchronousflushSyncmode flips (exit + re-enter). Doing that gesture twice leaves the element atopacity: 0. - Bug 2 drives
JSAnimationwith a manual driver andMotionGlobalConfig.useManualTiming, restarts the value exactly when the previous animation reaches its duration, and checks thatvalue.animationstill points at the new animation and thatvalue.stop()stops it.
src/index.tsx of the sandbox
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { MotionGlobalConfig } from "motion-utils";
import { JSAnimation, frameData, motionValue, type MotionValue } from "motion-dom";
// ─── Bug 1 — AnimatePresence: a child re-entering during its exit can stay
// stuck at `initial` forever (opacity 0, scale 0).
//
// Deterministic with `MotionGlobalConfig.instantAnimations`: animations settle
// on the next frame, so the exit resolves AFTER the element has re-entered —
// exactly the one-frame window we hit in CI with springs.
const frame = () => new Promise<void>((r) => setTimeout(r, 60));
function Bug1() {
const [mode, setMode] = useState<"bar" | "panel">("bar");
const [result, setResult] = useState("running…");
useEffect(() => {
let cancelled = false;
(async () => {
MotionGlobalConfig.instantAnimations = true;
// 1. exit + re-enter before the frame → the exit promise resolves after
// re-entry → ExitAnimationFeature.isExitComplete becomes stale (true).
flushSync(() => setMode("panel"));
flushSync(() => setMode("bar"));
await frame();
// 2. same gesture → re-entry takes the "at rest" branch → jump(initial)
// + reset() + animateChanges(), which blockInitialAnimation disables
// (first child of <AnimatePresence initial={false}>) → stuck.
flushSync(() => setMode("panel"));
flushSync(() => setMode("bar"));
await frame();
await frame();
MotionGlobalConfig.instantAnimations = false;
if (cancelled) return;
const el = document.querySelector<HTMLElement>('[data-testid="bar"]');
const ok = !!el && el.style.opacity === "1";
setResult(
ok
? "PASS — bar visible"
: `FAIL — bar stuck: opacity=${el?.style.opacity} transform=${el?.style.transform}`
);
})();
return () => {
cancelled = true;
};
}, []);
return (
<section>
<h2>Bug 1 — AnimatePresence re-entry: {result}</h2>
<div style={{ height: 60 }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={mode}
data-testid={mode}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
style={{ padding: 12, background: mode === "bar" ? "#3a6" : "#a63", color: "#fff" }}
>
{mode === "bar" ? "bar (should be visible)" : "panel"}
</motion.div>
</AnimatePresence>
</div>
</section>
);
}
// ─── Bug 2 — MotionValue.start(): the previous animation's `.then(() =>
// this.clearAnimation())` runs AFTER `this.animation = next`, so it deletes
// the reference to the NEW animation when the previous one finishes inside
// `stop()` (JSAnimation.stop() does a last tick). The new animation becomes an
// orphan: unstoppable, and it wins against any later animation on the value.
type Driver = { tick: (t: number) => void };
const makeDriver = (register: (d: Driver) => void) => (update: (t: number) => void) => {
register({ tick: update });
return { start: () => {}, stop: () => {}, now: () => frameData.timestamp };
};
const startLinear = (value: MotionValue<number>, to: number, duration: number, register: (d: Driver) => void) =>
value.start(
(onComplete) =>
new JSAnimation({
keyframes: [value.get(), to],
duration,
ease: (t: number) => t,
driver: makeDriver(register),
motionValue: value,
onUpdate: (v) => value.set(v),
onComplete,
})
);
async function bug2(): Promise<string> {
MotionGlobalConfig.useManualTiming = true;
frameData.timestamp = 0;
const value = motionValue(0);
let a: Driver | undefined;
let b: Driver | undefined;
startLinear(value, 1, 100, (d) => (a = d));
frameData.timestamp = 90;
await Promise.resolve(); // time.now() caches until the next microtask
a!.tick(90);
frameData.timestamp = 100;
await Promise.resolve();
startLinear(value, 0, 100, (d) => (b = d)); // stop() finishes A inside this call
const bAnimation = value.animation;
await Promise.resolve();
await Promise.resolve(); // A's `.then(clearAnimation)` runs here
const stillReferenced = value.animation === bAnimation && value.isAnimating();
value.stop();
const frozen = value.get();
frameData.timestamp = 150;
await Promise.resolve();
b!.tick(150);
const stoppable = value.get() === frozen;
MotionGlobalConfig.useManualTiming = false;
return stillReferenced && stoppable
? "PASS"
: `FAIL — value.animation ${stillReferenced ? "kept" : "was cleared (orphan)"}; stop() ${stoppable ? "worked" : "did NOT stop it (value moved to " + value.get().toFixed(3) + ")"}`;
}
function Bug2() {
const [result, setResult] = useState("running…");
useEffect(() => {
bug2().then(setResult);
}, []);
return (
<section>
<h2>Bug 2 — MotionValue.start() orphan: {result}</h2>
</section>
);
}
// No StrictMode: its double-invoked effects would run the scripted gestures twice.
createRoot(document.getElementById("root")!).render(
<main style={{ fontFamily: "system-ui", padding: 24 }}>
<h1>motion 12.40.0 — AnimatePresence re-entry freeze</h1>
<Bug1 />
<Bug2 />
</main>
);
4. Steps to reproduce
With real springs (what we hit in CI), on an AnimatePresence initial={false} mode="popLayout" whose first child has initial/animate/exit on opacity+scale with { type: "spring", stiffness: 260, damping: 26 }:
- Swap the child key (exit A / enter B), then swap back before A's exit completes (A survives as the mounted instance with
blockInitialAnimation === true). - Swap again, and swap back exactly as A's exit reaches its computed duration (~600 ms): the exit promise resolves after the re-entry → stale
isExitComplete. - Swap again, and swap back a few dozen ms later (mid-exit): A jumps to
initialand never animates back.
The sandbox replaces the timing with instantAnimations to make it deterministic.
5. Expected behavior
A child that re-enters is visible again: its enter animation (or a continuation from the current values) always plays, whatever the timing of the previous exit, and MotionValue.start() never loses the reference to the animation it just started.
6. Tests (Jest + jsdom, red on 12.40.0, green with the patches below)
motion-value-restart-race.spec.ts — Defect B
import { JSAnimation, frameData, motionValue, type MotionValue } from 'motion-dom'
import { MotionGlobalConfig } from 'motion-utils'
type Driver = { tick: (t: number) => void }
/** Pilote manuel : aucune rAF, on avance le temps à la main. */
function makeDriver(register: (d: Driver) => void) {
return (update: (timestamp: number) => void) => {
register({ tick: update })
return {
start: () => {},
stop: () => {},
now: () => frameData.timestamp,
}
}
}
function startLinear(value: MotionValue<number>, to: number, duration: number, register: (d: Driver) => void) {
return value.start(
(onComplete) =>
new JSAnimation({
keyframes: [value.get(), to],
duration,
ease: (t: number) => t,
driver: makeDriver(register),
motionValue: value,
onUpdate: (v) => value.set(v),
onComplete,
}),
)
}
describe('MotionValue.start() — redémarrage au moment où la précédente se termine', () => {
beforeEach(() => {
MotionGlobalConfig.useManualTiming = true
frameData.timestamp = 0
})
afterEach(() => {
MotionGlobalConfig.useManualTiming = false
})
it('la nouvelle animation reste référencée et arrêtable', async () => {
const value = motionValue(0)
let a: Driver | undefined
let b: Driver | undefined
// A : 0 → 1 en 100 ms. On la joue jusqu'à 90 ms.
startLinear(value, 1, 100, (d) => (a = d))
// `time.now()` met son horloge en cache jusqu'à la prochaine microtâche :
// après chaque avance du temps, on cède une microtâche pour la vider.
frameData.timestamp = 90
await Promise.resolve()
a!.tick(90)
expect(value.get()).toBeCloseTo(0.9, 5)
// À t = 100 ms, on démarre B (→ 0). `stop()` fait un dernier tick de A à
// t = 100 : A se TERMINE dans ce tick, sa promesse se résout.
frameData.timestamp = 100
await Promise.resolve()
startLinear(value, 0, 100, (d) => (b = d))
const bAnimation = value.animation
expect(bAnimation).toBeDefined()
// Microtâches : le `.then(clearAnimation)` de A s'exécute ici.
await Promise.resolve()
await Promise.resolve()
// Contrat : B est TOUJOURS l'animation courante de la valeur…
expect(value.animation).toBe(bAnimation)
expect(value.isAnimating()).toBe(true)
// … et `stop()` l'arrête vraiment : un tick ultérieur ne doit plus écrire.
value.stop()
const frozen = value.get()
frameData.timestamp = 150
await Promise.resolve()
b!.tick(150)
expect(value.get()).toBe(frozen)
})
})
animate-presence-reentry.spec.tsx — Defect A (public API)
import { act, render } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
import { MotionGlobalConfig } from 'motion-utils'
let setMode: (m: 'bar' | 'panel') => void = () => {}
function Island() {
const [mode, set] = useState<'bar' | 'panel'>('bar')
useEffect(() => {
setMode = set
}, [])
return (
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={mode}
data-testid={mode}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
>
{mode}
</motion.div>
</AnimatePresence>
)
}
const frame = () => new Promise<void>((resolve) => setTimeout(resolve, 40))
describe('AnimatePresence — ré-entrée pendant la sortie (premier enfant, initial={false})', () => {
beforeEach(() => {
MotionGlobalConfig.instantAnimations = true
})
afterEach(() => {
MotionGlobalConfig.instantAnimations = false
})
it('un enfant qui ré-entre reste visible, même quand sa sortie précédente s’est terminée après sa ré-entrée', async () => {
const { getByTestId } = render(<Island />)
const bar = () => getByTestId('bar') as HTMLElement
// 1. Sortie puis ré-entrée AVANT la frame : la sortie se termine après la
// ré-entrée → `isExitComplete` devient périmé (true sur un présent).
act(() => setMode('panel'))
act(() => setMode('bar'))
await act(frame)
expect(bar().style.opacity).toBe('1')
// 2. Même geste : cette ré-entrée-ci prend la branche « au repos »
// (drapeau périmé) → jump(initial) + reset + animateChanges bloqué.
act(() => setMode('panel'))
act(() => setMode('bar'))
await act(frame)
await act(frame)
expect(bar().style.opacity).toBe('1')
expect(bar().style.transform === 'none' || bar().style.transform === '').toBe(true)
})
})
7. Proposed fixes (we run them as pnpm patches on 12.40.0; happy to open a PR)
motion-dom — only clear the reference if it is still ours:
diff --git a/dist/es/value/index.mjs b/dist/es/value/index.mjs
index 36993917f5300721cd2ea0372eedcbf6343c9513..0e800f55167b8415ef8a7a06ee08cd881d3a3898 100644
--- a/dist/es/value/index.mjs
+++ b/dist/es/value/index.mjs
@@ -259,9 +259,10 @@ class MotionValue {
*/
start(startAnimation) {
this.stop();
+ let animation;
return new Promise((resolve) => {
this.hasAnimated = true;
- this.animation = startAnimation(resolve);
+ animation = this.animation = startAnimation(resolve);
if (this.events.animationStart) {
this.events.animationStart.notify();
}
@@ -269,7 +270,18 @@ class MotionValue {
if (this.events.animationComplete) {
this.events.animationComplete.notify();
}
- this.clearAnimation();
+ if (this.animation === animation) {
+ this.clearAnimation();
+ }
});
}
/**
framer-motion — do not record "at rest" for an exit that completed after re-entry, and let the "at rest" re-entry replay the enter even when blockInitialAnimation is set:
diff --git a/dist/es/motion/features/animation/exit.mjs b/dist/es/motion/features/animation/exit.mjs
index 2519f77b3177dfaadad5ff436d4331c0cd9c00f2..dbf4770e48b045b53c65a9e0cd7b8a131f7aac26 100644
--- a/dist/es/motion/features/animation/exit.mjs
+++ b/dist/es/motion/features/animation/exit.mjs
@@ -37,8 +37,19 @@ class ExitAnimationFeature extends Feature {
}
}
}
+ const { blockInitialAnimation } = this.node;
+ this.node.blockInitialAnimation = false;
this.node.animationState.reset();
this.node.animationState.animateChanges();
+ this.node.blockInitialAnimation = blockInitialAnimation;
}
else {
this.node.animationState.setActive("exit", false);
@@ -47,9 +58,22 @@ class ExitAnimationFeature extends Feature {
return;
}
const exitAnimation = this.node.animationState.setActive("exit", !isPresent);
+ this.currentExit = exitAnimation;
if (onExitComplete && !isPresent) {
exitAnimation.then(() => {
- this.isExitComplete = true;
+ if (this.currentExit === exitAnimation &&
+ this.node.presenceContext?.isPresent === false) {
+ this.isExitComplete = true;
+ }
onExitComplete(this.id);
});
}
8. Environment details
motion / framer-motion / motion-dom 12.40.0, React 19.2.7, Next.js 16 production build; observed in headless Chromium (Playwright 1.60, GitHub Actions ubuntu-latest) and reproduced in Chromium locally and in jsdom.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with packages/motion-dom/src/value/index.ts#start and packages/framer-motion/src/motion/features/animation/exit.ts, then run the deterministic reproductions in src/index.tsx from the linked CodeSandbox. Done means the AnimatePresence re-entry remains visible and the newly started MotionValue animation stays referenced and can be stopped without an orphan continuing to write.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100