plans/issues/issue-2567.md
layout prop becomes true after mountExecutor 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 — do not improvise. When done, update the row for this issue in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2567 --jq .state→ must beopen.git diff --stat 42bfbe3ed..HEAD -- packages/framer-motion/src/motion/utils/use-visual-element.ts packages/motion-dom/src/projection/node/create-projection-node.ts packages/framer-motion/src/motion/features/layout/MeasureLayout.tsxMismatch with "Current state" excerpts = STOP. Also check whether plans/issues/issue-1411.md has been executed (see Depends on).
42bfbe3ed, 2026-06-11Setting layout to true after mount does nothing — the element never
layout-animates until remounted. Reporter use case: virtualized/expanding
lists where layout is enabled lazily for perf. The cause is identical in
kind to issue #1411 (projection options frozen at node creation), so fixing
them together is the cheap path.
Root cause has two halves, both keyed off mount-time props:
Options never sync.
packages/framer-motion/src/motion/utils/use-visual-element.ts:97-109:
the projection node is created on FIRST render (the ProjectionNode
constructor is supplied whenever layout features are loaded, regardless of
isEnabled — see getProjectionFunctionality,
packages/framer-motion/src/motion/index.tsx:204-219, which returns
ProjectionNode: combined.ProjectionNode unconditionally). So a div
rendered with layout={undefined} gets a projection node whose
options.layout === undefined, and setOptions is never called again
with the new prop (TODO at use-visual-element.ts:221-227).
The layout-animation listener is never attached.
packages/motion-dom/src/projection/node/create-projection-node.ts:493-498
— in mount():
if (this.options.animate !== false && visualElement && (layoutId || layout)) {
this.addEventListener("didUpdate", ...)
With layout falsy at mount, the didUpdate handler that starts layout
animations is missing forever.
What DOES already work when layout flips to true: MeasureLayout
(packages/framer-motion/src/motion/features/layout/MeasureLayout.tsx)
mounts at that point because getProjectionFunctionality re-evaluates
isEnabled(props) every render (feature props list:
packages/framer-motion/src/motion/features/definitions.ts:23
layout: ["layout", "layoutId"]). Its componentDidMount (lines 39-66) adds
the node to the LayoutGroup and calls projection.setOptions({...projection.options, layoutDependency, onExitComplete})
— but that spread copies the STALE layout: undefined, and willUpdate/
didUpdate snapshots are taken for a node that never starts animations.
So the symptom in the repro (items rendered before "Toggle" never animate; items rendered after do) is fully explained by the code above.
Reproduction sandbox
(https://codesandbox.io/p/sandbox/framer-virtual-hgzcgz) was unreachable at
planning time (CodeSandbox blocked, HTTP 403). The issue body's steps are
complete enough to rebuild: list of items, "Add" button, "Toggle" flips a
boolean passed as layout={enabled} to every item.
Same as plans/issues/issue-1411.md (build, motion-dom jest, Cypress React
18/19 recipe, HTML projection suite). New spec name:
cypress/integration/layout-prop-dynamic.ts.
In scope:
packages/framer-motion/src/motion/utils/use-visual-element.ts (option
sync — shared with issue-1411)packages/motion-dom/src/projection/node/create-projection-node.ts
(idempotent listener attach — shared with issue-1411)dev/react/src/tests/layout-prop-dynamic.tsx (create)packages/framer-motion/cypress/integration/layout-prop-dynamic.ts (create)Out of scope:
layout OFF dynamically (removing the listener). Acceptable to
leave the listener attached and rely on synced options.layout being
falsy; verify with a test case rather than building teardown machinery.fix/2567-dynamic-layout-prop (or fold into the issue-1411 branch
if executing together — preferred; one PR closing both issues is fine).dev/react/src/tests/layout-prop-dynamic.tsx exporting App, modeled on the
issue repro:
<motion.div layout={layoutEnabled} /> items, each ~50px tall,
with ids #item-0, #item-1, ...; start with 2 items.#add prepends an item (so existing items move down); button
#toggle flips layoutEnabled.transition={{ type: "tween", ease: "linear", duration: 10 }} on items.Spec layout-prop-dynamic.ts:
#toggle (now layout=true on already-mounted items).#add.cy.wait(500) then .then() on #item-0's bounding rect: with the fix,
the displaced item should be mid-animation (visually near its OLD
position, offset < item height); on current main it will already sit at
its final position (no animation). Assert the mid-animation position.Verify: spec FAILS on unpatched main (React 18 recipe). Capture output
with tail -60 on first run.
If issue-1411 landed: confirm its option-sync in useInsertionEffect already
copies layout/animationType and that setOptions calls the extracted
attachLayoutAnimationListener() when (layout || layoutId) becomes truthy.
If it covers layoutId but not layout, extend both trigger conditions to
include layout.
If issue-1411 did NOT land: implement its Steps 3–4 (listener extraction +
prop→option sync in useInsertionEffect), restricted to what this issue
needs (layout, animationType), following that plan's exact instructions.
Additionally make sure animationType is recomputed on sync:
typeof layout === "string" ? layout : "both"
(use-visual-element.ts:228).
Verify: yarn build exits 0; Step 1 spec passes on React 18 AND 19.
--spec "cypress/integration/layout.ts,cypress/integration/layout-shared.ts,cypress/integration/layout-group.ts"
(layout.ts / layout-group.ts are known flaky — re-run once before
treating red as real).cd packages/framer-motion && yarn test-client → matches pre-change
baseline.layout-prop-dynamic.ts fails on unpatched main, passes with fix (React 18 + 19)test-client at baselinegit status)plans/issues/README.md row updatedcreate-projection-node.ts, or issue-1411 implemented differently than its
plan) — reconcile by reading the landed code, and report before deviating.MeasureLayout mount ordering — that interacts with
snapshot timing (hasTakenAnySnapshot, MeasureLayout.tsx:31) — report
first.layoutScroll, layoutRoot changing dynamically) — same
mechanism now fixes them for free; mention in PR description.