From e21187922696c28c491532de5376f568a15d7b90 Mon Sep 17 00:00:00 2001 From: George Reade Date: Tue, 20 Oct 2026 10:18:02 +0100 Subject: [PATCH 1/2] fix: skip off-screen travel in Sheet enter animation --- packages/react-aria-components/src/Sheet.tsx | 56 +++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/react-aria-components/src/Sheet.tsx b/packages/react-aria-components/src/Sheet.tsx index 6b7b169666a..d31db09b3c0 100644 --- a/packages/react-aria-components/src/Sheet.tsx +++ b/packages/react-aria-components/src/Sheet.tsx @@ -363,6 +363,7 @@ export const SheetOverlay = forwardRef(function SheetOverlay( onEnter={element => { // The overlay is the document-anchored wrapper; the swipe gesture scrolls the inner container. let scroller = element.querySelector('[data-sheet-scroll]')!; + let content = element.querySelector('[data-sheet-content]'); let vp = axis === 'y' ? window.innerHeight + parseFloat(calcToPx(viewportPadding(after, axis))) @@ -377,18 +378,37 @@ export const SheetOverlay = forwardRef(function SheetOverlay( } }; if (enteredScroll === 0) { - // The rest position is at scroll 0, so the sheet mounts already entered. Jump to the exit - // first, then animate back in on the next frame — doing both in the same task can leave - // the browser stuck at the jumped-to position instead of running the smooth scroll. + // The rest position is at scroll 0, so the sheet mounts already entered. Jump to the exit first. scrollAlongAxis(scroller, axis, (maxScroll / 100) * vp); - requestAnimationFrame(() => scrollToRest(true)); - } else { - scrollToRest(true); } + // Skip the part of the enter scroll where the sheet is still entirely off screen. + let snapType = scroller.style.scrollSnapType; + let skipped = + content != null && !reduceMotion() && skipOffscreenTravel(scroller, content, axis, vp); return new Promise(resolve => { - // eslint-disable-next-line rsp-rules/no-non-composing-event-listener - scroller.addEventListener('scrollend', () => resolve(), {once: true}); + let startEnterScroll = () => { + scrollToRest(true); + // Listen only once the smooth scroll has started, so the jump's own scrollend is ignored. + // eslint-disable-next-line rsp-rules/no-non-composing-event-listener + scroller.addEventListener( + 'scrollend', + () => { + if (skipped) { + scroller.style.scrollSnapType = snapType; + } + resolve(); + }, + {once: true} + ); + }; + + // Jumping and smooth scrolling in the same task can leave the browser stuck at the jumped-to position. + if (enteredScroll === 0 || skipped) { + requestAnimationFrame(startEnterScroll); + } else { + startEnterScroll(); + } }); }} onExit={element => { @@ -1113,6 +1133,26 @@ function scrollAlongAxis(element: HTMLElement, axis: Axis, value: number, smooth element.scrollTo(axis === 'y' ? {top: value, behavior} : {left: value, behavior}); } +// Moves the scroller so the sheet's leading edge sits at the viewport edge, with snapping +// disabled so the browser doesn't pull it back to a snap point. Returns whether it moved. +function skipOffscreenTravel(scroller: HTMLElement, content: HTMLElement, axis: Axis, vp: number) { + let rect = content.getBoundingClientRect(); + let start = axis === 'y' ? rect.top : rect.left; + let end = axis === 'y' ? rect.bottom : rect.right; + let delta = start > vp ? start - vp : end < 0 ? end : 0; + if (delta === 0) { + return false; + } + + scroller.style.scrollSnapType = 'none'; + if (axis === 'y') { + scroller.scrollTop += delta; + } else { + scroller.scrollLeft += delta; + } + return true; +} + function scrollDetentIntoView( scroller: HTMLElement, marker: HTMLElement, From 50bb8166796dc7928ce948b0956d840d46fb0991 Mon Sep 17 00:00:00 2001 From: George Reade Date: Tue, 20 Oct 2026 10:20:05 +0100 Subject: [PATCH 2/2] fix: add tests for Sheet enter scroll --- .../react-aria-components/test/Sheet.test.js | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/react-aria-components/test/Sheet.test.js b/packages/react-aria-components/test/Sheet.test.js index 062aa6b9cc6..aa842a4bbfa 100644 --- a/packages/react-aria-components/test/Sheet.test.js +++ b/packages/react-aria-components/test/Sheet.test.js @@ -316,6 +316,98 @@ describe('Sheet', () => { } }); + describe('enter scroll', () => { + let rect; + function mockContentRect(contentRect) { + rect = jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function () { + return this.hasAttribute('data-sheet-content') + ? {top: 0, bottom: 0, left: 0, right: 0, ...contentRect} + : {top: 0, bottom: 0, left: 0, right: 0}; + }); + } + + beforeEach(() => { + // jsdom skips enter animations unless getAnimations is implemented. + Element.prototype.getAnimations = () => []; + }); + + afterEach(() => { + rect?.mockRestore(); + rect = undefined; + delete Element.prototype.getAnimations; + }); + + it('skips the part where the sheet is still off screen past the end edge', async () => { + mockContentRect({top: window.innerHeight + 200, bottom: window.innerHeight + 500}); + let tree = render(); + await open(tree); + let scroller = tree.getByTestId('overlay').querySelector('[data-sheet-scroll]'); + + expect(scroller.scrollTop).toBe(200); + expect(scroller.style.scrollSnapType).toBe('none'); + + act(() => jest.runAllTimers()); + expect(scroller.scrollTop).toBe(window.innerHeight); + + await act(async () => { + scroller.dispatchEvent(new Event('scrollend')); + }); + expect(scroller.style.scrollSnapType).toBe('y mandatory'); + }); + + it('skips the part where the sheet is still off screen past the start edge', async () => { + mockContentRect({left: -500, right: -200}); + let tree = render(); + await open(tree); + let scroller = tree.getByTestId('overlay').querySelector('[data-sheet-scroll]'); + + expect(scroller.scrollLeft).toBe(window.innerWidth - 200); + expect(scroller.style.scrollSnapType).toBe('none'); + + act(() => jest.runAllTimers()); + expect(scroller.scrollLeft).toBe(0); + + await act(async () => { + scroller.dispatchEvent(new Event('scrollend')); + }); + expect(scroller.style.scrollSnapType).toBe('x mandatory'); + }); + + it('does not skip when the sheet is already partly on screen', async () => { + mockContentRect({top: window.innerHeight - 100, bottom: window.innerHeight + 200}); + let tree = render(); + await open(tree); + let scroller = tree.getByTestId('overlay').querySelector('[data-sheet-scroll]'); + + expect(scroller.scrollTop).toBe(window.innerHeight); + expect(scroller.style.scrollSnapType).toBe('y mandatory'); + }); + + it('ignores the scrollend from the jump and waits for the enter scroll to finish', async () => { + mockContentRect({top: window.innerHeight + 200, bottom: window.innerHeight + 500}); + let tree = render(); + await open(tree); + let overlay = tree.getByTestId('overlay'); + let scroller = overlay.querySelector('[data-sheet-scroll]'); + + // The instant jump fires its own scrollend before the smooth scroll starts on the next frame. + await act(async () => { + scroller.dispatchEvent(new Event('scrollend')); + }); + expect(scroller.style.scrollSnapType).toBe('none'); + expect(overlay).toHaveAttribute('data-entering'); + + act(() => jest.runAllTimers()); + await act(async () => { + scroller.dispatchEvent(new Event('scrollend')); + }); + expect(scroller.style.scrollSnapType).toBe('y mandatory'); + expect(overlay).not.toHaveAttribute('data-entering'); + }); + }); + it('closes via the close function passed to SheetContent children', async () => { let tree = render(); let dialog = await open(tree);