plans/issues/issue-3745.md
Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in "STOP conditions" occurs, stop and report — do not improvise. When done, update (or add) this plan's row in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/3745 --jq .state→ expectopen. Re-read the "Current state" excerpt ofPopChild.tsxlines 91–94 and 143–149 against the live file; on a mismatch, treat as a STOP condition.
pop === false)42bfbe3ed, 2026-06-11In React 18.3, creating an element with a ref prop installs a warning getter
on element.props.ref. PopChild reads children.props?.ref unconditionally
on every render — including mode="sync" / mode="wait", where the value is
never used — so any <AnimatePresence> wrapping a motion.* child that has a
ref logs Warning: [object Object]: \ref` is not a prop.` once per app run
(noisy in every test run; reporter is shipping a pnpm patch to work around
it). The issue includes an exact, verified-correct fix.
packages/framer-motion/src/components/AnimatePresence/PopChild.tsx:91-94:
const childRef =
(children.props as { ref?: React.Ref<HTMLElement> })?.ref ??
(children as unknown as { ref?: React.Ref<HTMLElement> })?.ref
const composedRef = useComposedRefs(ref, childRef)
PopChild.tsx:143-149:
return (
<PopChildMeasure isPresent={isPresent} childRef={ref} sizeRef={size} pop={pop}>
{pop === false
? children
: React.cloneElement(children as any, { ref: composedRef })}
</PopChildMeasure>
)
pop === false (not falsy check) — PresenceChild.tsx:90
always passes pop={mode === "popLayout"}, but PopChild's pop prop is
optional and undefined behaves as pop-active (line 145 clones). Your gate
must match: read the ref unless pop === false.useComposedRefs (packages/framer-motion/src/utils/use-composed-ref.ts) is
a hook — it must remain called unconditionally; only the childRef
argument becomes conditional. Passing undefined is fine
(PossibleRef<T> = React.Ref<T> | undefined).^18.3.1 (root package.json:70), so Jest unit
tests run the exact React version that exhibits the warning.specialPropRefWarningShown); Jest isolates module registries per test
file, so the regression test must live in its own file and contain only
this assertion.__tests__/AnimatePresence.test.tsx:630)
and Cypress animate-presence-pop-ref.ts.| Purpose | Command | Expected |
|---|---|---|
| Build | yarn build (repo root) | exit 0 |
| New test | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="PopChild" | fails pre-fix, passes post-fix |
| Suite | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="AnimatePresence" | pass |
| Cypress | CLAUDE.md § "Running Cypress tests locally", spec cypress/integration/animate-presence-pop-ref.ts, React 18 AND 19 | pass |
In scope:
packages/framer-motion/src/components/AnimatePresence/PopChild.tsx (the 2-line gate)packages/framer-motion/src/components/AnimatePresence/__tests__/PopChild-ref-warning.test.tsx (create)CHANGELOG.mdOut of scope: useComposedRefs; PresenceChild.tsx; any popLayout
behavior change; React 19 ref handling (works today, must keep working).
Branch fix/issue-3745-popchild-ref-read. Commit message style: e.g.
Fix PopChild reading child ref prop when popLayout is inactive. gh pr edit
is broken on this repo — use gh api -X PATCH repos/motiondivision/motion/pulls/<n>
for body edits.
Create __tests__/PopChild-ref-warning.test.tsx (own file — see the
once-per-module-registry note above). Single test:
jest.spyOn(console, "error").mockImplementation(() => {}) (React 18
emits this warning via console.error).render helper as AnimatePresence.test.tsx, i.e.
../../../jest.setup):
const ref = createRef<HTMLDivElement>()
render(
<AnimatePresence>
<motion.div key="a" ref={ref} exit={{ opacity: 0 }} />
</AnimatePresence>
)
"sync" → pop === false path.)console.error call whose first args stringify to something
matching /ref.*is not a prop/.Verify: test FAILS on unmodified main with the ref is not a prop
warning captured. If it does NOT fail, check (a) React version actually
resolved in node_modules/react/package.json is 18.3.x, (b) the JSX dev
runtime is in use; if it still can't be made to fail after 2–3 attempts, STOP
(see STOP conditions).
In PopChild.tsx, replace lines 91–94's assignment with:
const childRef =
pop === false
? undefined
: (children.props as { ref?: React.Ref<HTMLElement> })?.ref ??
(children as unknown as { ref?: React.Ref<HTMLElement> })?.ref
const composedRef = useComposedRefs(ref, childRef)
Keep the explanatory React 19 comment above it (lines 87–90) intact.
Verify: Step 1's test passes.
Run the AnimatePresence Jest suite, then Cypress animate-presence-pop-ref.ts
and animate-presence-pop.ts on React 18 and React 19 per the CLAUDE.md
recipe. Add CHANGELOG entry under ## Unreleased → ### Fixed.
Verify: all pass; yarn build exits 0.
PopChild-ref-warning.test.tsx — sync-mode AnimatePresence + child with
ref produces no React ref-prop warning (the bug). Failing-first.AnimatePresence.test.tsx:630) proves popLayout ref composition still
works; Cypress animate-presence-pop-ref.ts on both React versions.npx jest --config packages/framer-motion/jest.config.json --testPathPattern="AnimatePresence|PopChild" exits 0animate-presence-pop-ref.ts + animate-presence-pop.ts green on React 18 and 19yarn build exits 0; CHANGELOG updated; no out-of-scope files touchedplans/issues/README.md row updatedpop === false) — per CLAUDE.md's environment-specific-bug guidance you MAY
proceed, but the test must then assert the gate's observable behavior
(e.g. that a Proxy-wrapped props object's ref getter is not invoked in
sync mode) and the PR must state the test couldn't fail in-harness. If you
can't construct even that, STOP and report.PopChild.tsx lines 91–94 / 143–149 no longer match the excerpts.PopChild ever needs the child ref outside the clone branch, the gate
must be revisited — leave a comment pointing at issue #3745.props.ref/element.ref read can be simplified together.