plans/issues/issue-2319.md
Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving on. If anything in "STOP conditions" occurs, stop and report. When done, update (or add) this plan's row in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2319 --jq .state→ expectopen. Re-read the PopChild excerpts below against the live file; mismatch = STOP.
root prop keeps precedence)42bfbe3ed, 2026-06-11Feature request (2023-08): apps that render React trees into a separate
window via window.open() get broken popLayout, because PopChild injects
its <style> element into the main window's document.head, which can't
affect elements living in the other window's document. The reporter proposed
using the element's ownerDocument and offered a PR.
Key fact discovered during planning: since the issue was filed, commit
c1f485cf3 (2024-10-26) added a root prop to AnimatePresence —
packages/framer-motion/src/components/AnimatePresence/types.ts:55-59:
/**
* Root element to use when injecting styles, used when mode === `"popLayout"`.
* This defaults to document.head but can be overridden e.g. for use in shadow DOM.
*/
root?: HTMLElement | ShadowRoot;
A user can already pass the external window's document.head as root,
which likely satisfies the request manually. The remaining delta is making it
automatic via ownerDocument. So this plan starts with a decision gate.
packages/framer-motion/src/components/AnimatePresence/PopChild.tsx:115-121:
ref.current.dataset.motionPopId = id
const style = document.createElement("style")
if (nonce) style.nonce = nonce
const parent = root ?? document.head
parent.appendChild(style)
createElement call and the document.head fallback use the
module-global document. ref.current is guaranteed non-null at this point
(guard at line 107).root prop: Cypress spec
packages/framer-motion/cypress/integration/animate-presence-pop-shadow-root.ts
dev/react/src/tests/animate-presence-pop-shadow-root.tsx.ownerDocument.createElement is the
correct, explicit form.| Purpose | Command | Expected |
|---|---|---|
| Build | yarn build (repo root) | exit 0 |
| Jest | `npx jest --config packages/framer-motion/jest.config.json --testPathPattern="AnimatePresence | PopChild"` |
| Cypress | CLAUDE.md § "Running Cypress tests locally", specs animate-presence-pop.ts, animate-presence-pop-shadow-root.ts, React 18 AND 19 | pass |
In scope (only on APPROVED-IMPLEMENT):
packages/framer-motion/src/components/AnimatePresence/PopChild.tsx (lines 115–121 only)packages/framer-motion/src/components/AnimatePresence/__tests__/PopChild-owner-document.test.tsx (create)CHANGELOG.mdOut of scope: any StyleSheetManager/provider-style API (reporter listed
alternatives; the root prop already covers explicit control); changing
root precedence; portal handling elsewhere in the library.
Check this plan's row in plans/issues/README.md:
APPROVED-IMPLEMENT → Steps 2–4.APPROVED-CLOSE → Step 5 (answer + close).BLOCKED("awaiting maintainer decision: implement ownerDocument default vs close as satisfied by root prop"),
post nothing, stop.Recommendation for the maintainer: implement. It is ~2 lines, strictly more
correct (root ?? ownerDocument.head preserves the explicit override), and
also fixes iframes for free.
JSDOM can host a second document: document.implementation.createHTMLDocument()
or an <iframe>'s contentDocument. New Jest test
PopChild-owner-document.test.tsx:
<AnimatePresence mode="popLayout"> content via
ReactDOM.createPortal (or directly with a container) into an element that
belongs to an iframe's contentDocument appended to the main DOM.act + frame flush per existing
AnimatePresence tests).<style> element containing data-motion-pop-id rule text exists
in the iframe document's head, and none was added to the main
document.head.Verify: fails on unmodified main (style lands in main document.head).
If JSDOM's iframe document proves unusable after 2–3 attempts, fall back to
createHTMLDocument + manually mounting; if that also can't host a React
render, STOP and report (a Cypress page with a same-origin iframe is the
escalation, modeled on animate-presence-pop-shadow-root.tsx).
const doc = ref.current.ownerDocument || document
const style = doc.createElement("style")
if (nonce) style.nonce = nonce
const parent = root ?? doc.head
parent.appendChild(style)
(root keeps precedence; behavior is identical for the 99% case where
ownerDocument === document.)
Verify: Step 2 test passes; full Jest pattern passes; Cypress
animate-presence-pop.ts + animate-presence-pop-shadow-root.ts green on
React 18 and 19; yarn build exit 0. Add CHANGELOG entry under
## Unreleased → ### Added (or ### Fixed, match file style).
gh pr create referencing #2319 and crediting the reporter's original
prototype commit. (gh pr edit is broken — gh api -X PATCH .../pulls/<n>.)
Comment: the root prop (shipped after this issue, c1f485cf3) lets you pass
the external window's document.head/container —
<AnimatePresence mode="popLayout" root={externalDoc.head}> — and link the
shadow-root example. Then close with
gh api -X PATCH repos/motiondivision/motion/issues/2319 -f state=closed -f state_reason=completed
(fallback if gh issue close fails). Only with APPROVED-CLOSE on the README
row — otherwise BLOCKED, stop.
plans/issues/README.md row updatedroot and the automatic ownerDocument behavior.PopChildMeasure reads getComputedStyle(element) (global) at
PopChild.tsx:50 — JSDOM tolerates cross-document elements there, but if
separate-window users report measurement bugs later, switch it to
element.ownerDocument.defaultView.getComputedStyle in a follow-up.