Provide a general summary of the issue here
In a dismissable ModalOverlay, pressing a button that the app re-renders or removes while the press is in flight can close the modal, even though the pointer never left it.
The press stops belonging to the modal. Once the pressed element is out of the modal's subtree, dialog.contains(event.target) is false and event.composedPath() no longer includes the overlay ref, so useInteractOutside reports an interaction outside and the overlay dismisses.
isValidEvent already guards against a target that has left the document, but the check is nodeContains(ownerDocument.documentElement, target), and contains is true of the root element itself. The browser reports documentElement as the target here, so the guard passes and the press is treated as a genuine press outside.
🤔 Expected Behavior
Pressing a button inside a modal does not dismiss it, even if that button is re-rendered or removed while the press is in flight.
😯 Current Behavior
The modal dismisses. Both pointerdown and click for that press arrive with documentElement as the target, the pointer does not move between them, and elementsFromPoint at that position still lists a button inside the dialog. useInteractOutside requires both events to look outside before it fires, and a press where only the click was retargeted did not dismiss, which is consistent with that.
One thing I could not explain is why the target is the root rather than a nearer ancestor. Retargeting itself is expected: a click fires on "the most specific ancestor element that contained both elements" (MDN), and the removed-target case is still being settled (w3c/pointerevents#492). But Chrome names the nearest still-connected ancestor. A button that removes itself in its own pointerdown listener gives pointerdown=BUTTON, mousedown=DIV, pointerup=DIV, mouseup=DIV and no click at all. The button's parent was still in the tree in our case, so that route would have named the parent.
Ruled out by measurement: nothing throws, the dialog's subtree is not unmounted, inert is unchanged on every body child, pointer-events is auto on html and body, there is no scrollbar gutter, and the toast region is present but does not cover the press position.
💁 Possible Solution
Is documentElement ever a legitimate outside-press target for a modal whose underlay covers the viewport? If not, rejecting it in useModalOverlay would close this without changing what the check gained in #1369, which widened it from body to documentElement so a non-modal overlay on a page with a short body stays dismissable (#1367).
The same guard shape exists elsewhere: Headless UI checks isConnected on the target for the same reason (headlessui#869, a CodeMirror editor replacing its own nodes inside a dialog). That check has the same hole, since the root is connected, so this looks like a gap in the common approach rather than something specific to react-aria.
Our workaround is shouldCloseOnInteractOutside={element => element !== element.ownerDocument.documentElement} on the ModalOverlay. It has held up across our modals and drawers.
🔦 Context
A table inside a modal, each row with an "Add" button. A press runs a mutation, and when it resolves the row re-renders and the button is swapped for a disabled "Added" one. Pressing several rows in quick succession closes the modal, so people reopen it for every row. It is a timing problem: the press has to land while an earlier mutation is resolving, so it is intermittent and hard to trigger deliberately.
🖥️ Steps to Reproduce
I do not have a minimal repro. In our app: a table inside a dismissable ModalOverlay, each row button runs an async mutation, and the row re-renders when it resolves. Press buttons in quick succession so one resolves while the next press is in flight.
Version
react-aria-components 1.20.0, @react-aria/interactions 3.27.1, @react-aria/overlays 3.31.2
What browsers are you seeing the problem on?
Chrome 151.0.7922.173 (arm64)
If other, please specify.
Investigated with AI assistance.
What operating system are you using?
macOS
Provide a general summary of the issue here
In a dismissable
ModalOverlay, pressing a button that the app re-renders or removes while the press is in flight can close the modal, even though the pointer never left it.The press stops belonging to the modal. Once the pressed element is out of the modal's subtree,
dialog.contains(event.target)is false andevent.composedPath()no longer includes the overlay ref, souseInteractOutsidereports an interaction outside and the overlay dismisses.isValidEventalready guards against a target that has left the document, but the check isnodeContains(ownerDocument.documentElement, target), andcontainsis true of the root element itself. The browser reportsdocumentElementas the target here, so the guard passes and the press is treated as a genuine press outside.🤔 Expected Behavior
Pressing a button inside a modal does not dismiss it, even if that button is re-rendered or removed while the press is in flight.
😯 Current Behavior
The modal dismisses. Both
pointerdownandclickfor that press arrive withdocumentElementas the target, the pointer does not move between them, andelementsFromPointat that position still lists a button inside the dialog.useInteractOutsiderequires both events to look outside before it fires, and a press where only theclickwas retargeted did not dismiss, which is consistent with that.One thing I could not explain is why the target is the root rather than a nearer ancestor. Retargeting itself is expected: a click fires on "the most specific ancestor element that contained both elements" (MDN), and the removed-target case is still being settled (w3c/pointerevents#492). But Chrome names the nearest still-connected ancestor. A button that removes itself in its own
pointerdownlistener givespointerdown=BUTTON, mousedown=DIV, pointerup=DIV, mouseup=DIVand noclickat all. The button's parent was still in the tree in our case, so that route would have named the parent.Ruled out by measurement: nothing throws, the dialog's subtree is not unmounted,
inertis unchanged on every body child,pointer-eventsisautoon html and body, there is no scrollbar gutter, and the toast region is present but does not cover the press position.💁 Possible Solution
Is
documentElementever a legitimate outside-press target for a modal whose underlay covers the viewport? If not, rejecting it inuseModalOverlaywould close this without changing what the check gained in #1369, which widened it frombodytodocumentElementso a non-modal overlay on a page with a short body stays dismissable (#1367).The same guard shape exists elsewhere: Headless UI checks
isConnectedon the target for the same reason (headlessui#869, a CodeMirror editor replacing its own nodes inside a dialog). That check has the same hole, since the root is connected, so this looks like a gap in the common approach rather than something specific to react-aria.Our workaround is
shouldCloseOnInteractOutside={element => element !== element.ownerDocument.documentElement}on theModalOverlay. It has held up across our modals and drawers.🔦 Context
A table inside a modal, each row with an "Add" button. A press runs a mutation, and when it resolves the row re-renders and the button is swapped for a disabled "Added" one. Pressing several rows in quick succession closes the modal, so people reopen it for every row. It is a timing problem: the press has to land while an earlier mutation is resolving, so it is intermittent and hard to trigger deliberately.
🖥️ Steps to Reproduce
I do not have a minimal repro. In our app: a table inside a dismissable
ModalOverlay, each row button runs an async mutation, and the row re-renders when it resolves. Press buttons in quick succession so one resolves while the next press is in flight.Version
react-aria-components1.20.0,@react-aria/interactions3.27.1,@react-aria/overlays3.31.2What browsers are you seeing the problem on?
Chrome 151.0.7922.173 (arm64)
If other, please specify.
Investigated with AI assistance.
What operating system are you using?
macOS