Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions dev/react/src/tests/reorder-intersection-observer.tsx
Original file line number Diff line number Diff line change
@@ -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<IntersectionLog[]>([])
const containerRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const root = containerRef.current
if (!root) return

const observer = new IntersectionObserver(
(entries) => {
const time = performance.now()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The project's timing guideline (CLAUDE.md) says to use time.now() from motion-dom/src/frameloop/sync-time.ts instead of performance.now(). While this is a dev test file and the timestamp is used only for logging, keeping it consistent with the rest of the codebase avoids ambiguity about which clock source to use.

Suggested change
const time = performance.now()
const time = Date.now()

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

setLogs((prev) => [
...prev,
...entries.map((entry) => ({
id: (entry.target as HTMLElement).id,
isIntersecting: entry.isIntersecting,
time,
})),
])
},
{ root, threshold: 0 }
)

const targets = root.querySelectorAll<HTMLElement>("[data-observed]")
targets.forEach((el) => observer.observe(el))

return () => observer.disconnect()
}, [])

return (
<div>
<div
ref={containerRef}
style={{
height: 400,
overflow: "auto",
border: "1px solid #333",
}}
>
<Reorder.Group
axis="y"
values={items}
onReorder={setItems}
style={{
listStyle: "none",
padding: 0,
margin: 0,
}}
>
{items.map((item) => (
<Reorder.Item
key={item}
value={item}
id={item.replace(/\s/g, "-")}
data-observed
style={{
height: 80,
padding: 10,
boxSizing: "border-box",
background: "#eef",
borderBottom: "1px solid #99c",
cursor: "grab",
}}
>
{item}
</Reorder.Item>
))}
</Reorder.Group>
</div>
<div
id="log-state"
data-count={logs.length}
data-log={JSON.stringify(logs)}
/>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,13 @@ export function createProjectionNode<I>({
? 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
}
Comment on lines +2003 to +2006
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Using instanceof HTMLElement would be more type-safe than double-casting through unknown. Since this.instance is typed as the generic I (which can be SVG or HTML), the cast to HTMLElement silently does nothing on SVG elements (.offsetWidth is undefined there). An explicit guard keeps the intent clearer and avoids the double-cast smell without changing runtime behaviour.

Suggested change
if (this.instance) {
void (this.instance as unknown as HTMLElement)
.offsetWidth
}
if (this.instance instanceof HTMLElement) {
void this.instance.offsetWidth
}

}

return
Expand Down