plans/issues/issue-2538.md
Executor instructions: Follow step by step; run every verification command. STOP conditions are binding. When done, update (or add) this plan's row in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2538 --jq .state→ expectopen.
42bfbe3ed, 2026-06-11Reported March 2024: onExitComplete fires before the exiting child's React
unmount (its useEffect cleanup logs after the callback). The reporter
expects post-unmount timing. The reported ordering is real and still true on
current main — but it matches the documented contract, which is about
animations, not unmounting. Changing it would alter observable timing for
every existing onExitComplete user. So this is a by-design answer with an
explicit, gated opt-in path if the maintainer wants the deferral.
packages/framer-motion/src/components/AnimatePresence/types.ts:35-40:
/**
* Fires when all exiting nodes have completed animating out.
*/
onExitComplete?: () => void
packages/framer-motion/src/components/AnimatePresence/index.tsx:205-212
(inside onExit):
if (isEveryExitComplete) {
forceRender?.()
setRenderedChildren(pendingPresentChildren.current)
propagate && safeToRemove?.()
onExitComplete && onExitComplete()
}
setRenderedChildren only schedules the commit that removes the exiting
child; onExitComplete() runs synchronously in the same tick, hence before
the child's effect cleanup. This is exactly what the reporter observed
(their StackBlitz framer-motion-animate-presence-q-ufdg7s was not
fetchable from the planning environment; the issue text fully describes it).| Purpose | Command | Expected |
|---|---|---|
| Jest (only if opt-in path) | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="AnimatePresence" | pass |
| Issue close (gated) | gh api -X PATCH repos/motiondivision/motion/issues/2538 -f state=closed -f state_reason=not_planned | closed |
Comment on #2538, covering:
types.ts:35-40): the callback marks the
end of exit animations; unmount is the React commit that follows.useEffect cleanup (it runs on the actual unmount, as their log shows), or
flag state in onExitComplete and react to it in an effect that runs after
the next commit.Close as state_reason=not_planned (works-as-documented) ONLY if this plan's
row in plans/issues/README.md is APPROVED (or APPROVED-CLOSE). Otherwise set
the row to BLOCKED("awaiting maintainer decision: by-design close vs deferral
change") and stop.
If the maintainer instead wants onExitComplete deferred until after the
removal commit: move the invocation out of onExit into the existing
useIsomorphicLayoutEffect (index.tsx:102-121) — fire a pending flag in
onExit, then call onExitComplete from the effect when it observes
renderedChildren equal to pendingPresentChildren.current with the flag
set. Requirements if taken:
useEffect cleanup
runs BEFORE onExitComplete (this is the reporter's exact observation and
will fail on current main).onExitComplete
(grep -rn "onExitComplete" packages/framer-motion/src packages/framer-motion/cypress)
— e.g. "Fires onExitComplete during rapid key switches…"
(AnimatePresence.test.tsx:1320) and the Cypress
animate-presence-exit-complete-multiple.ts — all must still pass.git status clean on the answer-only pathonExitComplete test depends on current timing
by design.index.tsx:205-212 excerpt has drifted.