diff --git a/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts b/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts index 151b0f3006..fe83c33108 100644 --- a/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts +++ b/packages/framer-motion/src/render/dom/scroll/__tests__/index.test.ts @@ -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((resolve) => { + frame.postRender(() => resolve()) + }) +} + describe("scrollInfo", () => { test("Fires onScroll on creation.", async () => { const onScroll = jest.fn() @@ -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 diff --git a/packages/framer-motion/src/render/dom/scroll/track.ts b/packages/framer-motion/src/render/dom/scroll/track.ts index e6cbf382c5..9a3fca6f9d 100644 --- a/packages/framer-motion/src/render/dom/scroll/track.ts +++ b/packages/framer-motion/src/render/dom/scroll/track.ts @@ -9,6 +9,13 @@ const resizeListeners = new WeakMap() const onScrollHandlers = new WeakMap>() const scrollSize = new WeakMap() const dimensionCheckProcesses = new WeakMap() +const measureProcesses = new WeakMap() + +/** + * 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 @@ -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 + 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) @@ -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) {