plans/issues/issue-2385.md
Executor instructions: Follow this plan step by step. This is an investigation-first plan: the reproduction gate (Step 2) decides whether a fix is attempted at all. Repo policy is no repro → no fix — if you cannot make the test fail on the bug, do not land a speculative fix or happy-path tests. Run every verification command; on any STOP condition, stop and report. When done, update this issue's row in
plans/issues/README.md.Drift check (run first):
git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/projection/node/create-projection-node.tsOn any change, re-verify the "Current state" excerpts. Confirm issue open:gh api repos/motiondivision/motion/issues/2385 --jq .state→open.
42bfbe3ed, 2026-06-11Reordering a grid of layout elements sometimes resets the page scroll
position (reporter: "reset to the top"; a 2025-01-13 comment from ynamite
confirms it still occurs intermittently with setInterval random reorders).
Scroll jumps during layout animations are highly visible UX breakage and the
issue has multiple confirmations across 2023–2025.
ynamite's variant: grid randomly reordered on
setInterval, page scrolled, scroll position intermittently changes.packages/motion-dom/src/projection/node/create-projection-node.ts:772-789:
this.nodes!.forEach(ensureDraggedNodesSnapshotted)
// Write
this.nodes!.forEach(resetTransformStyle) // strips transforms from all dirty nodes
// Read
this.nodes!.forEach(updateLayout) // getBoundingClientRect measurements
// Write
this.nodes!.forEach(notifyLayoutUpdate) // re-applies transforms / starts animations
scrollHeight; resetTransformStyle strips
those transforms, the document shrinks, and the browser synchronously clamps
scrollTop — before notifyLayoutUpdate re-applies transforms. Matches the
"sometimes" character (only when current scroll depends on transform-extended
overflow, i.e. scrolled near the bottom during an in-flight animation).updateScroll
(create-projection-node.ts:942-965) records measureScroll(this.instance)
during snapshot/measure phases — so a clamp that happens before/at measure
also poisons the recorded offsets.e98d0ebb5 (2021, "Removing scroll reset")
removed scrollTop save/restore around resetTransform in the old HTML
visual-element. Read it (git show e98d0ebb5) — it confirms transform reset
vs scroll has been a live concern, and that a naive per-element save/restore
was deliberately removed (find out why before reintroducing anything:
git log --format="%H %s" --all -S "scrollTop" -- src/render/html/visual-element.ts).| Purpose | Command | Expected |
|---|---|---|
| Build | yarn build (repo root) | exit 0 |
| Cypress React 18 | block below | spec result per step |
| Cypress React 19 | same with dev/react-19 + --config-file=cypress.react-19.json | same |
| Unit tests | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="layout" | no new failures |
PORT=$((10000 + RANDOM % 50000))
cd dev/react && TEST_PORT=$PORT yarn vite --port $PORT &
DEV_PID=$!
npx wait-on http://localhost:$PORT
cd packages/framer-motion && cypress run --headed --config baseUrl=http://localhost:$PORT --spec cypress/integration/layout-scroll-reset.ts
kill $DEV_PID
In scope:
dev/react/src/tests/layout-scroll-reset.tsx (create)packages/framer-motion/cypress/integration/layout-scroll-reset.ts (create)packages/motion-dom/src/projection/node/create-projection-node.ts — ONLY
the root update() cycle (:725-800 region), and only if Step 2 reproduces.Out of scope:
Reorder/* (plans 015–018 own those files; this issue is plain layout,
not Reorder).e98d0ebb5
removed) — any fix must be root-scroll-scoped and measured.LayoutAnimationBuilder.ts (PR #3748 territory).fix/issue-2385-layout-scroll-resetgh pr edit is broken on this repo; use gh api -X PATCH repos/motiondivision/motion/pulls/<n> for edits.WebFetch https://codepen.io/JAWStm/pen/ExrjQZj (and
https://codepen.io/JAWStm/pen/ExrjQZj.js). If accessible, base the test page
on it verbatim. If still blocked, proceed with the reconstruction in Step 2
(the issue text is specific enough); note the blockage in the PR body.
dev/react/src/tests/layout-scroll-reset.tsx, exporting App:
motion.div layout items so
the page scrolls well past 100vh), each with a stable key and
transition={{ type: "tween", ease: "linear", duration: 1 }}.?mode=interval variant that random-shuffles every
300ms (ynamite's variant — shuffles land mid-animation, which the hypothesis
says is required).cypress/integration/layout-scroll-reset.ts:
cy.scrollTo("bottom"), record win.scrollY..then() immediately read win.scrollY again.scrollY unchanged (±2px). Repeat the trigger ~10 times in the test
to catch the intermittency.Verify (must FAIL on unmodified main to proceed): spec fails with a scroll delta. If it passes after 3 honest variations (scrolled to bottom vs middle; click-reorder vs interval shuffle; tall items vs grid), STOP: this is the NEEDS-REPRO exit (see STOP conditions).
With the failing test, instrument locally (temporary console.log, not
committed): log document.documentElement.scrollHeight and window.scrollY
immediately before resetTransformStyle loop and after updateLayout loop
(create-projection-node.ts:777-783). Expected confirmation: scrollHeight
drops and scrollY clamps between those two points. If the clamp happens
elsewhere (e.g. during React's DOM reorder, before update() runs at all),
the fix below is wrong — STOP and report the actual mechanism.
Minimal shape (root node only, inside update() after the
notifyLayoutUpdate write phase at :789):
resetTransformStyle loop, capture the root's live offset:
const prevScroll = measureScroll(this.instance) equivalent for the root
(for the document root, window.scrollX/Y; reuse the platform
measureScroll passed into createProjectionNode at :143 — check how
HTMLProjectionNode/DocumentProjectionNode supply it).notifyLayoutUpdate (transforms re-applied, overflow restored),
compare the live offset; if it changed, write prevScroll back
(window.scrollTo(prevScroll.x, prevScroll.y) for the document root).this === this.root and a delta actually occurred — keep
the hot path free of unconditional writes, and keep byte cost minimal.Mind the recorded scroll: if Step 3 shows updateScroll captured the clamped
offset during updateLayout, the page-relative boxes are internally
consistent with the clamped scroll, and restoring afterwards must not skew
animation targets — the Step 2 spec plus Step 6 regression suite are the
arbiters. If targets skew (items animate from wrong origins after restore),
STOP and report; the alternative (restoring scroll between reset and
measure) re-grows overflow only if transforms are re-applied first, which
contradicts the read phase — that redesign is beyond this plan.
Verify: yarn build → exit 0; Step 2 spec now passes 10/10 iterations.
Run the spec per the command block on React 18 AND React 19 → both pass.
cypress run ... --spec cypress/integration/layout.ts (known-flaky: re-run
once; twice-red = STOP) and --spec cypress/integration/layout-group.ts.cypress.html.json suite specs
covering dev/html/public/projection/*page-scroll*.html (e.g.
flexbox-siblings-to-grid-page-scroll.html,
single-element-layout-change-page-scroll.html) via the dev/html server —
see plans/issues/pr-3748.md / memory notes for the dev/html Vite loop.npx jest --config packages/framer-motion/jest.config.json --testPathPattern="layout" → no new failures.yarn lint exits 0plans/issues/README.md row updatedgh api -X PATCH repos/motiondivision/motion/issues/2385 -f state=closed -f state_reason=not_planned)
once this plan's row in plans/issues/README.md is set to APPROVED-CLOSE.update() — report mechanism,
no speculative patch.update() no longer matches the excerpt —
re-ground; if the cycle moved into LayoutAnimationBuilder, hand findings
back instead of patching two places.updateScroll,
removeElementScroll) — that's a redesign; report.e98d0ebb5 removed an older
per-element variant of scroll preservation — reviewer should compare why
that was removed vs the root-scoped approach here.scrollend/anchor-scrolling interaction (browser scroll anchoring also
fights transform-driven overflow changes) is a plausible adjacent cause;
if Step 3 implicates scroll anchoring instead, the fix may be documenting
overflow-anchor: none guidance rather than code.