From 07236d91dead771bafda6ecc870e5809ef6f1cea Mon Sep 17 00:00:00 2001 From: Muhammed Hasan Ince Date: Thu, 22 Oct 2026 22:16:05 +0300 Subject: [PATCH 1/2] fix: finish Sheet exit once the scroll rests at the exit position Co-Authored-By: Claude Opus 5.5 --- packages/react-aria-components/src/Sheet.tsx | 22 ++++++++++++- .../react-aria-components/test/Sheet.test.js | 32 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/Sheet.tsx b/packages/react-aria-components/src/Sheet.tsx index d31db09b3c0..a936707607a 100644 --- a/packages/react-aria-components/src/Sheet.tsx +++ b/packages/react-aria-components/src/Sheet.tsx @@ -448,8 +448,28 @@ export const SheetOverlay = forwardRef(function SheetOverlay( scrollAlongAxis(scroller, axis, target, true); // The close event is dispatched on the overlay wrapper by the IntersectionObserver below. + // Also finish once the scroll rests at the exit position, in case the observer's last report + // never arrives (seen on busy machines), so the overlay doesn't stay over the page. return new Promise(resolve => { - element.addEventListener('react-aria-sheet-close', () => resolve(), {once: true}); + let isAtTarget = () => + Math.abs((axis === 'y' ? scroller.scrollTop : scroller.scrollLeft) - target) < 1; + let onScrollEnd = () => { + if (isAtTarget()) { + done(); + } + }; + let done = () => { + element.removeEventListener('react-aria-sheet-close', done); + scroller.removeEventListener('scrollend', onScrollEnd); + resolve(); + }; + element.addEventListener('react-aria-sheet-close', done); + // eslint-disable-next-line rsp-rules/no-non-composing-event-listener + scroller.addEventListener('scrollend', onScrollEnd); + // A scroll to the current position fires no scrollend. + if (isAtTarget()) { + done(); + } }); }}> {renderProps => ( diff --git a/packages/react-aria-components/test/Sheet.test.js b/packages/react-aria-components/test/Sheet.test.js index aa842a4bbfa..aed9fc6008a 100644 --- a/packages/react-aria-components/test/Sheet.test.js +++ b/packages/react-aria-components/test/Sheet.test.js @@ -556,6 +556,38 @@ describe('Sheet', () => { expect(triggerOnOpenChange).not.toHaveBeenCalled(); }); + it('finishes closing once the exit scroll ends, even if the sheet is never reported hidden', async () => { + // jsdom skips exit animations unless getAnimations is implemented. + Element.prototype.getAnimations = () => []; + try { + let tree = render(); + await open(tree); + let overlay = tree.getByTestId('overlay'); + let scroller = overlay.querySelector('[data-sheet-scroll]'); + await act(async () => { + scroller.dispatchEvent(new Event('scrollend')); + }); + intersect(1); + + // Hold the smooth exit scroll back until its scrollend. + let scrollTo = Element.prototype.scrollTo; + let finishScroll; + Element.prototype.scrollTo = function (options) { + finishScroll = () => scrollTo.call(this, options); + }; + await user.keyboard('{Escape}'); + expect(overlay).toHaveAttribute('data-exiting'); + + await act(async () => { + finishScroll(); + scroller.dispatchEvent(new Event('scrollend')); + }); + expect(tree.queryByRole('dialog')).toBeNull(); + } finally { + delete Element.prototype.getAnimations; + } + }); + it('does not close before the sheet has become visible', async () => { let onOpenChange = jest.fn(); let tree = render(); From 312888ee66c28361ac8bcdfdfc165a8181a13d15 Mon Sep 17 00:00:00 2001 From: Muhammed Hasan Ince Date: Fri, 23 Oct 2026 10:18:25 +0300 Subject: [PATCH 2/2] test: move the Sheet exit fallback test to a browser test Co-Authored-By: Claude Opus 5.5 --- packages/react-aria-components/src/Sheet.tsx | 10 +-- .../test/Sheet.browser.test.tsx | 61 +++++++++++++++++++ .../react-aria-components/test/Sheet.test.js | 32 ---------- 3 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 packages/react-aria-components/test/Sheet.browser.test.tsx diff --git a/packages/react-aria-components/src/Sheet.tsx b/packages/react-aria-components/src/Sheet.tsx index a936707607a..350449499de 100644 --- a/packages/react-aria-components/src/Sheet.tsx +++ b/packages/react-aria-components/src/Sheet.tsx @@ -453,16 +453,16 @@ export const SheetOverlay = forwardRef(function SheetOverlay( return new Promise(resolve => { let isAtTarget = () => Math.abs((axis === 'y' ? scroller.scrollTop : scroller.scrollLeft) - target) < 1; - let onScrollEnd = () => { - if (isAtTarget()) { - done(); - } - }; let done = () => { element.removeEventListener('react-aria-sheet-close', done); scroller.removeEventListener('scrollend', onScrollEnd); resolve(); }; + let onScrollEnd = () => { + if (isAtTarget()) { + done(); + } + }; element.addEventListener('react-aria-sheet-close', done); // eslint-disable-next-line rsp-rules/no-non-composing-event-listener scroller.addEventListener('scrollend', onScrollEnd); diff --git a/packages/react-aria-components/test/Sheet.browser.test.tsx b/packages/react-aria-components/test/Sheet.browser.test.tsx new file mode 100644 index 00000000000..da1eb6408e2 --- /dev/null +++ b/packages/react-aria-components/test/Sheet.browser.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Button} from '../src/Button'; +import {expect, it} from 'vitest'; +import {Heading} from '../src/Heading'; +import {page, userEvent} from 'vitest/browser'; +import React from 'react'; +import {render} from 'vitest-browser-react'; +import {Sheet, SheetContent, SheetOverlay, SheetTrigger} from '../src/Sheet'; + +function SheetExample() { + return ( + + + + + + Sheet title + + + + + ); +} + +it('finishes closing once the exit scroll ends, even if the sheet is never reported hidden', async () => { + // Drop the observer's report that the sheet has left the viewport. + let NativeIntersectionObserver = window.IntersectionObserver; + window.IntersectionObserver = class extends NativeIntersectionObserver { + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + super((entries, observer) => { + if (entries.every(entry => entry.intersectionRatio > 0)) { + callback(entries, observer); + } + }, options); + } + }; + + try { + await render(); + await userEvent.click(page.getByRole('button', {name: 'Open sheet'})); + // The dialog is focused once the sheet has entered. + let dialog = page.getByRole('dialog').element(); + await expect.poll(() => document.activeElement).toBe(dialog); + + await userEvent.keyboard('{Escape}'); + await expect.poll(() => document.querySelector('[role="dialog"]')).toBeNull(); + } finally { + window.IntersectionObserver = NativeIntersectionObserver; + } +}); diff --git a/packages/react-aria-components/test/Sheet.test.js b/packages/react-aria-components/test/Sheet.test.js index aed9fc6008a..aa842a4bbfa 100644 --- a/packages/react-aria-components/test/Sheet.test.js +++ b/packages/react-aria-components/test/Sheet.test.js @@ -556,38 +556,6 @@ describe('Sheet', () => { expect(triggerOnOpenChange).not.toHaveBeenCalled(); }); - it('finishes closing once the exit scroll ends, even if the sheet is never reported hidden', async () => { - // jsdom skips exit animations unless getAnimations is implemented. - Element.prototype.getAnimations = () => []; - try { - let tree = render(); - await open(tree); - let overlay = tree.getByTestId('overlay'); - let scroller = overlay.querySelector('[data-sheet-scroll]'); - await act(async () => { - scroller.dispatchEvent(new Event('scrollend')); - }); - intersect(1); - - // Hold the smooth exit scroll back until its scrollend. - let scrollTo = Element.prototype.scrollTo; - let finishScroll; - Element.prototype.scrollTo = function (options) { - finishScroll = () => scrollTo.call(this, options); - }; - await user.keyboard('{Escape}'); - expect(overlay).toHaveAttribute('data-exiting'); - - await act(async () => { - finishScroll(); - scroller.dispatchEvent(new Event('scrollend')); - }); - expect(tree.queryByRole('dialog')).toBeNull(); - } finally { - delete Element.prototype.getAnimations; - } - }); - it('does not close before the sheet has become visible', async () => { let onOpenChange = jest.fn(); let tree = render();