From 6e8250aaeba37563c645f41a16d0c0018d0752ee Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 12 May 2026 15:40:44 +0200 Subject: [PATCH] Flush layout after projection transform removal so Chrome's IntersectionObserver re-fires After a Reorder.Item settles back into a layout position, the projection system removes the transform style by setting it to "none". Chrome's IntersectionObserver can fail to re-evaluate the element's intersection state when transform transitions to "none" while the underlying DOM position has moved, leaving observers stuck reporting isIntersecting: false. Forcing a single offsetWidth read after the transform is cleared flushes the cached layout/geometry and nudges Chrome to re-fire its observers. Fixes #2679 --- .../tests/reorder-intersection-observer.tsx | 93 +++++++++++++++++++ .../reorder-intersection-observer.ts | 59 ++++++++++++ .../projection/node/create-projection-node.ts | 7 ++ 3 files changed, 159 insertions(+) create mode 100644 dev/react/src/tests/reorder-intersection-observer.tsx create mode 100644 packages/framer-motion/cypress/integration/reorder-intersection-observer.ts diff --git a/dev/react/src/tests/reorder-intersection-observer.tsx b/dev/react/src/tests/reorder-intersection-observer.tsx new file mode 100644 index 0000000000..03abb2cd6e --- /dev/null +++ b/dev/react/src/tests/reorder-intersection-observer.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef, useState } from "react" +import { Reorder } from "framer-motion" + +interface IntersectionLog { + id: string + isIntersecting: boolean + time: number +} + +export const App = () => { + const [items, setItems] = useState([ + "Test 1", + "Test 2", + "Test 3", + "Test 4", + "Test 5", + ]) + const [logs, setLogs] = useState([]) + const containerRef = useRef(null) + + useEffect(() => { + const root = containerRef.current + if (!root) return + + const observer = new IntersectionObserver( + (entries) => { + const time = performance.now() + setLogs((prev) => [ + ...prev, + ...entries.map((entry) => ({ + id: (entry.target as HTMLElement).id, + isIntersecting: entry.isIntersecting, + time, + })), + ]) + }, + { root, threshold: 0 } + ) + + const targets = root.querySelectorAll("[data-observed]") + targets.forEach((el) => observer.observe(el)) + + return () => observer.disconnect() + }, []) + + return ( +
+
+ + {items.map((item) => ( + + {item} + + ))} + +
+
+
+ ) +} diff --git a/packages/framer-motion/cypress/integration/reorder-intersection-observer.ts b/packages/framer-motion/cypress/integration/reorder-intersection-observer.ts new file mode 100644 index 0000000000..bcbd25a580 --- /dev/null +++ b/packages/framer-motion/cypress/integration/reorder-intersection-observer.ts @@ -0,0 +1,59 @@ +interface IntersectionLog { + id: string + isIntersecting: boolean + time: number +} + +function readLogs(win: Window): IntersectionLog[] { + const el = win.document.getElementById("log-state") + if (!el) return [] + const raw = el.getAttribute("data-log") + return raw ? JSON.parse(raw) : [] +} + +/** + * Regression test for https://github.com/motiondivision/motion/issues/2679 + * + * After dragging a Reorder.Item out of view and back in, the + * IntersectionObserver should fire callbacks reflecting the final visibility + * state — never leaving an item stuck reporting isIntersecting: false when it + * is actually visible. + * + * The underlying bug is a Chrome IntersectionObserver implementation quirk + * (it does not reproduce in Electron/Cypress), so this test documents the + * expected behaviour rather than reliably failing on the unfixed codebase. + */ +describe("Reorder + IntersectionObserver", () => { + it("Items report isIntersecting: true once visible after a reorder", () => { + cy.visit("?test=reorder-intersection-observer") + .wait(200) + // Drag Test-1 down past Test-2 to trigger a reorder + layout + // animation, then back to settle. + .get("#Test-1") + .trigger("pointerdown", 50, 20, { force: true }) + .wait(50) + .trigger("pointermove", 50, 40, { force: true }) + .wait(50) + .trigger("pointermove", 50, 100, { force: true }) + .wait(100) + .trigger("pointerup", 50, 100, { force: true }) + .wait(500) + .window() + .then((win) => { + const logs = readLogs(win) + // Every observed item must end its log with + // isIntersecting: true — none should be stuck "not visible". + const ids = new Set(logs.map((l) => l.id)) + ids.forEach((id) => { + const final = [...logs] + .reverse() + .find((entry) => entry.id === id) + expect(final, `final state for ${id}`).to.exist + expect( + final!.isIntersecting, + `${id} final isIntersecting` + ).to.equal(true) + }) + }) + }) +}) diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index 0a7dd5bf8b..295b1ed04d 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -1997,6 +1997,13 @@ export function createProjectionNode({ ? transformTemplate({}, "") : "none" this.hasProjected = false + + // Flush layout so Chrome's IntersectionObserver re-evaluates + // after the projection transform is removed (see #2679). + if (this.instance) { + void (this.instance as unknown as HTMLElement) + .offsetWidth + } } return