-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Fix wrong starting position for layout transitions in sticky containers #3657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import { motion } from "framer-motion" | ||
| import { useRef, useState } from "react" | ||
|
|
||
| /** | ||
| * Reproduction for #2941: layoutId transitions have wrong starting position | ||
| * when elements are inside a sticky container with a top offset. | ||
| */ | ||
|
|
||
| const MenuButton = ({ | ||
| label, | ||
| active, | ||
| onClick, | ||
| id, | ||
| indicatorRef, | ||
| }: { | ||
| label: string | ||
| active: boolean | ||
| onClick: () => void | ||
| id: string | ||
| indicatorRef: React.RefObject<HTMLDivElement | null> | ||
| }) => { | ||
| return ( | ||
| <button | ||
| id={id} | ||
| onClick={onClick} | ||
| style={{ | ||
| position: "relative", | ||
| border: "1px solid #ccc", | ||
| borderRadius: 8, | ||
| padding: "8px 16px", | ||
| background: "transparent", | ||
| cursor: "pointer", | ||
| }} | ||
| > | ||
| {label} | ||
| {active && ( | ||
| <motion.div | ||
| ref={indicatorRef} | ||
| id="indicator" | ||
| layoutId="indicator" | ||
| style={{ | ||
| position: "absolute", | ||
| inset: 0, | ||
| borderRadius: 8, | ||
| background: "rgba(255, 200, 0, 0.7)", | ||
| }} | ||
| transition={{ duration: 10, ease: "linear" }} | ||
| onLayoutAnimationStart={() => { | ||
| if (indicatorRef.current) { | ||
| const rect = | ||
| indicatorRef.current.getBoundingClientRect() | ||
| indicatorRef.current.dataset.startTop = | ||
| String(rect.top) | ||
| indicatorRef.current.dataset.scrollY = String( | ||
| window.scrollY | ||
| ) | ||
| } | ||
| }} | ||
| /> | ||
| )} | ||
| </button> | ||
| ) | ||
| } | ||
|
|
||
| export const App = () => { | ||
| const [active, setActive] = useState(1) | ||
| const indicatorRef = useRef<HTMLDivElement>(null) | ||
|
|
||
| return ( | ||
| <div style={{ display: "flex", width: "100%" }}> | ||
| <div> | ||
| <div | ||
| id="sticky-container" | ||
| style={{ | ||
| width: 120, | ||
| display: "flex", | ||
| flexDirection: "column", | ||
| gap: 8, | ||
| position: "sticky", | ||
| top: 20, | ||
| padding: 8, | ||
| }} | ||
| > | ||
| {[1, 2, 3, 4].map((v) => ( | ||
| <MenuButton | ||
| key={v} | ||
| id={`btn-${v}`} | ||
| label={v.toString()} | ||
| active={v === active} | ||
| onClick={() => setActive(v)} | ||
| indicatorRef={indicatorRef} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| <div | ||
| id="content" | ||
| style={{ | ||
| height: "500vh", | ||
| background: "#eee", | ||
| flex: 1, | ||
| }} | ||
| /> | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| describe("Shared layout: sticky container", () => { | ||
| it("Layout animation works inside a sticky container after scrolling", () => { | ||
| cy.visit("?test=layout-shared-sticky") | ||
| .wait(50) | ||
| // Click btn-2 so indicator moves there | ||
| .get("#btn-2") | ||
| .trigger("click") | ||
| .wait(300) | ||
| // Scroll down so sticky container kicks in | ||
| .window() | ||
| .then((win: any) => { | ||
| win.scrollTo(0, 2000) | ||
| }) | ||
| .wait(100) | ||
| // Click btn-3 to trigger layoutId animation while sticky | ||
| .get("#btn-3") | ||
| .trigger("click") | ||
| .wait(500) | ||
| .get("#indicator") | ||
| .then(([$indicator]: any) => { | ||
| const appDoc = $indicator.ownerDocument | ||
| const btn2 = ( | ||
| appDoc.querySelector("#btn-2") as HTMLElement | ||
| ).getBoundingClientRect() | ||
| const btn3 = ( | ||
| appDoc.querySelector("#btn-3") as HTMLElement | ||
| ).getBoundingClientRect() | ||
| const indicator = $indicator.getBoundingClientRect() | ||
| const scrollY = | ||
| appDoc.defaultView.scrollY || | ||
| appDoc.defaultView.pageYOffset | ||
|
|
||
| // The indicator is animating from btn-2 to btn-3 (10s linear). | ||
| // At 500ms (~5% progress), it should be between the two buttons. | ||
| // | ||
| // WITHOUT the fix, the starting position is offset by ~scrollY | ||
| // (2000px), so the indicator would be far outside the button range. | ||
| // | ||
| // WITH the fix, the indicator stays within the button area. | ||
| const buttonsMinTop = Math.min(btn2.top, btn3.top) | ||
| const buttonsMaxTop = Math.max(btn2.top, btn3.top) | ||
|
|
||
| // Indicator must be within the button range (with tolerance | ||
| // for animation overshoot). The key assertion: it must NOT | ||
| // be offset by the scroll amount. | ||
| expect(indicator.top).to.be.greaterThan( | ||
| buttonsMinTop - 50, | ||
| `Indicator (${indicator.top}) should be near buttons ` + | ||
| `(${buttonsMinTop}-${buttonsMaxTop}), ` + | ||
| `not offset by scroll (${scrollY})` | ||
| ) | ||
| expect(indicator.top).to.be.lessThan( | ||
| buttonsMaxTop + 50, | ||
| `Indicator (${indicator.top}) should be near buttons ` + | ||
| `(${buttonsMinTop}-${buttonsMaxTop}), ` + | ||
| `not offset by scroll (${scrollY})` | ||
| ) | ||
| }) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,4 +25,12 @@ export const HTMLProjectionNode = createProjectionNode<HTMLElement>({ | |
| }, | ||
| checkIsScrollRoot: (instance) => | ||
| Boolean(window.getComputedStyle(instance).position === "fixed"), | ||
| hasStickyAncestor: (instance) => { | ||
| let el = instance.parentElement | ||
| while (el) { | ||
| if (window.getComputedStyle(el).position === "sticky") return true | ||
| el = el.parentElement | ||
| } | ||
| return false | ||
| }, | ||
|
Comment on lines
+28
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In this scenario the element's To detect whether a sticky ancestor is actually engaged, you could compare the ancestor's hasStickyAncestor: (instance) => {
let el = instance.parentElement
while (el) {
const style = window.getComputedStyle(el)
if (style.position === "sticky") {
const stickyTop = parseFloat(style.top) || 0
// If the element is at (or near) its `top` value, it is engaged
if (el.getBoundingClientRect().top <= stickyTop + 1) return true
}
el = el.parentElement
}
return false
},The existing test only exercises the fully-engaged state (scroll to 2 000 px), so this case is not currently covered.
Comment on lines
+28
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider stopping the ancestor walk when an element with hasStickyAncestor: (instance) => {
let el = instance.parentElement
while (el) {
const style = window.getComputedStyle(el)
const overflow = style.overflowY
// Stop at a scroll container — sticky is relative to it, not the root
if (overflow === "scroll" || overflow === "auto") break
if (style.position === "sticky") return true
el = el.parentElement
}
return false
},
Comment on lines
+28
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For layouts with deep DOM hierarchies or many transformed ancestors this could accumulate meaningful cost at animation-start time. Consider caching the result on the projection node (invalidated on mount/unmount) so the walk is done at most once per animation batch rather than per-measurement. |
||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Cypress test scrolls to 2 000 px (far past the sticky threshold of
top: 20) and then triggers the animation. This validates the primary bug scenario but leaves two important cases untested:top: 200, scroll to 100 px, then animate. With the current implementationhasStickyAncestorstill returnstrueand the scroll offset is skipped, producing an animation starting position offset by-100 px.Adding a test variant at
scrollY < sticky.top(before engagement) would catch the regression described in thehasStickyAncestorcomments above.