Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ async function fireScroll(distance: number = 0) {
return nextFrame()
}

/**
* Advance the frame loop without dispatching a `scroll` event. This emulates a
* frame painted during native smooth scrolling where the compositor has moved
* the scroll position but the matching `scroll` event hasn't been delivered to
* JS yet.
*/
async function nextFrameWithoutScrollEvent() {
return new Promise<void>((resolve) => {
frame.postRender(() => resolve())
})
}

describe("scrollInfo", () => {
test("Fires onScroll on creation.", async () => {
const onScroll = jest.fn()
Expand All @@ -86,6 +98,38 @@ describe("scrollInfo", () => {
})
})

test("Keeps scroll progress in sync on the frame loop without a scroll event (#2716).", async () => {
let latest: ScrollInfo

const stopScroll = scrollInfo((info) => {
latest = info
})

setWindowHeight(1000)
setDocumentHeight(3000)

// Establish a baseline via a regular scroll event.
await fireScroll(0)
expect(latest!.y.current).toEqual(0)

// Let the frame loop settle so any pending measure has run.
await nextFrameWithoutScrollEvent()
await nextFrameWithoutScrollEvent()

// Native smooth scrolling (e.g. mouse wheel) advances the scroll
// position on the compositor, but the matching `scroll` event can be
// coalesced or delivered a frame late. Motion must still pick up the new
// position on the frame loop, otherwise the frame renders with a stale
// progress — the jumpy scrolling reported in #2716.
setScrollTop(500)
await nextFrameWithoutScrollEvent()

expect(latest!.y.current).toEqual(500)
expect(latest!.y.progress).toEqual(0.25)

stopScroll()
})

test("Fires onScroll on scroll.", async () => {
let latest: ScrollInfo

Expand Down
48 changes: 47 additions & 1 deletion packages/framer-motion/src/render/dom/scroll/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ const resizeListeners = new WeakMap<Element, VoidFunction>()
const onScrollHandlers = new WeakMap<Element, Set<OnScrollHandler>>()
const scrollSize = new WeakMap<Element, { width: number; height: number }>()
const dimensionCheckProcesses = new WeakMap<Element, Process>()
const measureProcesses = new WeakMap<Element, Process>()

/**
* Number of frames to keep reading the scroll position after it last changed
* before stopping, allowing the frameloop to sleep once scrolling settles.
*/
const maxSettleFrames = 10

export type ScrollTargets = Array<HTMLElement>

Expand Down Expand Up @@ -67,9 +74,41 @@ export function scrollInfo(
}
}

const listener = () => frame.read(measureAll)
/**
* Read the scroll position on every frame while the container is
* scrolling, rather than only when a `scroll` event fires. Native smooth
* scrolling (mouse wheel, trackpad momentum) advances the scroll offset
* on the compositor and can deliver `scroll` events coalesced or a frame
* late, so measuring purely on the event leaves some frames rendering a
* stale scroll progress — the "jumpy" scrolling reported in #2716. Once
* the position stops changing the loop is cancelled so the frameloop can
* sleep again.
*/
let settleFrames = 0
let prevTop = -1
let prevLeft = -1
Comment on lines +87 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Sentinel value -1 is a valid scrollTop/scrollLeft

In reversed-direction layouts (e.g. column-reverse, row-reverse, writing-mode: vertical-rl) some browsers report scrollTop / scrollLeft as negative values, and -1 is a legitimate initial position. If a container starts at exactly scrollTop = -1, the first invocation of measureLoop evaluates top !== prevTop as -1 !== -1false, so prevTop is never updated and settleFrames immediately starts counting toward maxSettleFrames. After 10 frames with the position "unchanged," the loop cancels. Any subsequent native-smooth-scroll movement (without a new scroll event) goes undetected — which is the exact regression this PR fixes. Using NaN as the sentinel (or a separate initialized boolean) guarantees the first run always treats the position as changed.

Suggested change
let settleFrames = 0
let prevTop = -1
let prevLeft = -1
let settleFrames = 0
let prevTop = NaN
let prevLeft = NaN

const measureLoop = () => {
const top = container.scrollTop
const left = container.scrollLeft

if (top !== prevTop || left !== prevLeft) {
settleFrames = 0
prevTop = top
prevLeft = left
}

measureAll()

if (++settleFrames > maxSettleFrames) cancelFrame(measureLoop)
}

const listener = () => {
settleFrames = 0
frame.read(measureLoop, true)
}

scrollListeners.set(container, listener)
measureProcesses.set(container, measureLoop)

const target = getEventTarget(container)
window.addEventListener("resize", listener)
Expand Down Expand Up @@ -143,6 +182,13 @@ export function scrollInfo(
window.removeEventListener("resize", scrollListener)
}

// Stop the per-frame scroll measurement loop
const measureProcess = measureProcesses.get(container)
if (measureProcess) {
cancelFrame(measureProcess)
measureProcesses.delete(container)
}

// Clean up scroll dimension checking
const dimensionCheckProcess = dimensionCheckProcesses.get(container)
if (dimensionCheckProcess) {
Expand Down