[BUG] AnimatePresence child re-entering during its exit stays stuck at initial (stale isExitComplete + blockInitialAnimation); MotionValue.start() orphans the next animation
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 [email protected]:
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 sandboximport { 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 Bimport { 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 t
Source: motiondivision/motion