From cf263949d76f9c0aeecde1bf881a93bac0d27458 Mon Sep 17 00:00:00 2001 From: FranKaddour Date: Tue, 25 Aug 2026 11:47:12 +0000 Subject: [PATCH] fix: ignore presses retargeted to the document element in dismissable modals --- .../src/overlays/useModalOverlay.ts | 14 ++++++++ .../test/overlays/useModalOverlay.test.js | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/react-aria/src/overlays/useModalOverlay.ts b/packages/react-aria/src/overlays/useModalOverlay.ts index 654c5d751de..e5787916ba0 100644 --- a/packages/react-aria/src/overlays/useModalOverlay.ts +++ b/packages/react-aria/src/overlays/useModalOverlay.ts @@ -53,9 +53,23 @@ export function useModalOverlay( state: OverlayTriggerState, ref: RefObject ): ModalOverlayAria { + let {shouldCloseOnInteractOutside} = props; let {overlayProps, underlayProps} = useOverlay( { ...props, + shouldCloseOnInteractOutside: (element) => { + // A modal's underlay covers the viewport, so the document element is never a + // legitimate outside-press target. It only appears as one when the browser + // retargets a press whose original target was removed from the DOM mid-press + // (e.g. a button swapped out after an async mutation), and dismissing then + // would close the modal under the user's pointer. Non-modal overlays must keep + // treating it as valid: on pages with a short body, presses below the body + // target the document element and should still dismiss (see #1367). + if (element === element.ownerDocument.documentElement) { + return false; + } + return shouldCloseOnInteractOutside ? shouldCloseOnInteractOutside(element) : true; + }, isOpen: state.isOpen, onClose: state.close }, diff --git a/packages/react-aria/test/overlays/useModalOverlay.test.js b/packages/react-aria/test/overlays/useModalOverlay.test.js index 0be3eb0b0b4..5be4fa9fcc2 100644 --- a/packages/react-aria/test/overlays/useModalOverlay.test.js +++ b/packages/react-aria/test/overlays/useModalOverlay.test.js @@ -62,5 +62,38 @@ describe('useModalOverlay', function () { fireEvent.click(document.body); expect(onOpenChange).not.toHaveBeenCalled(); }); + + it('should not hide the overlay when a press is retargeted to the document element', function () { + // When the pressed element is removed from the DOM mid-press (e.g. a button swapped + // out after an async mutation), the browser retargets the press events to the + // document element. That must not be treated as a press outside the modal. + let onOpenChange = jest.fn(); + render( + + ); + pressStart(document.documentElement); + pressEnd(document.documentElement); + fireEvent.click(document.documentElement); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('should ignore a retargeted press even if shouldCloseOnInteractOutside returns true', function () { + let onOpenChange = jest.fn(); + render( + true} + /> + ); + pressStart(document.documentElement); + pressEnd(document.documentElement); + fireEvent.click(document.documentElement); + expect(onOpenChange).not.toHaveBeenCalled(); + }); }); });