diff --git a/packages/react-aria/src/dnd/useDrop.ts b/packages/react-aria/src/dnd/useDrop.ts index adbd0d2712a..fb5d2fd87db 100644 --- a/packages/react-aria/src/dnd/useDrop.ts +++ b/packages/react-aria/src/dnd/useDrop.ts @@ -236,10 +236,16 @@ export function useDrop(options: DropOptions): DropResult { // events will never be fired for these. This can happen, for example, with drop // indicators between items, which disappear when the drop target changes. - state.dragOverElements.delete(getEventTarget(e)); - for (let element of state.dragOverElements) { - if (!nodeContains(e.currentTarget, element)) { - state.dragOverElements.delete(element); + let target = getEventTarget(e); + state.dragOverElements.delete(target); + + // Only remove stale elements when leaving the drop target itself. + // Avoids issues with portal children bubbling dragleave events through the React tree. + if (target === e.currentTarget) { + for (let element of state.dragOverElements) { + if (!nodeContains(e.currentTarget, element)) { + state.dragOverElements.delete(element); + } } } diff --git a/packages/react-aria/test/dnd/dnd.test.js b/packages/react-aria/test/dnd/dnd.test.js index 22f31deaac8..f92e95a7b2c 100644 --- a/packages/react-aria/test/dnd/dnd.test.js +++ b/packages/react-aria/test/dnd/dnd.test.js @@ -18,6 +18,7 @@ import {DataTransfer, DataTransferItem, DragEvent, FileSystemDirectoryEntry, Fil import {Draggable, Droppable} from './examples'; import {DragTypes} from '../../src/dnd/utils'; import React, {useEffect} from 'react'; +import ReactDOM from 'react-dom'; import userEvent from '@testing-library/user-event'; function pointerEvent(type, opts) { @@ -397,6 +398,53 @@ describe('useDrag and useDrop', function () { expect(onDropExit).toHaveBeenCalledTimes(1); }); + it('does not fire onDropEnter and onDropExit repeatedly for portal children', () => { + let onDropEnter = jest.fn(); + let onDropExit = jest.fn(); + let portalContainer = document.createElement('div'); + document.body.appendChild(portalContainer); + + try { + let tree = render( + + <> +
Drop here
+ {ReactDOM.createPortal( + <> +
Portal child 1
+
Portal child 2
+ , + portalContainer + )} + +
+ ); + + let portalChild1 = tree.getByText('Portal child 1'); + let portalChild2 = tree.getByText('Portal child 2'); + + let dataTransfer = new DataTransfer(); + fireEvent(portalChild1, new DragEvent('dragenter', {dataTransfer, clientX: 1, clientY: 1})); + expect(onDropEnter).toHaveBeenCalledTimes(1); + expect(onDropExit).not.toHaveBeenCalled(); + + fireEvent(portalChild2, new DragEvent('dragenter', {dataTransfer, clientX: 1, clientY: 1, relatedTarget: portalChild1})); + fireEvent(portalChild1, new DragEvent('dragleave', {dataTransfer, clientX: 1, clientY: 1, relatedTarget: portalChild2})); + expect(onDropEnter).toHaveBeenCalledTimes(1); + expect(onDropExit).not.toHaveBeenCalled(); + + fireEvent(portalChild1, new DragEvent('dragenter', {dataTransfer, clientX: 1, clientY: 1, relatedTarget: portalChild2})); + fireEvent(portalChild2, new DragEvent('dragleave', {dataTransfer, clientX: 1, clientY: 1, relatedTarget: portalChild1})); + expect(onDropEnter).toHaveBeenCalledTimes(1); + expect(onDropExit).not.toHaveBeenCalled(); + + fireEvent(portalChild1, new DragEvent('dragleave', {dataTransfer, clientX: 1, clientY: 1})); + expect(onDropExit).toHaveBeenCalledTimes(1); + } finally { + portalContainer.remove(); + } + }); + describe('nested drag targets', () => { let onDragStartParent = jest.fn(); let onDragMoveParent = jest.fn();