Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions packages/react-aria/src/dnd/useDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
48 changes: 48 additions & 0 deletions packages/react-aria/test/dnd/dnd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
<Droppable onDropEnter={onDropEnter} onDropExit={onDropExit}>
<>
<div>Drop here</div>
{ReactDOM.createPortal(
<>
<div>Portal child 1</div>
<div>Portal child 2</div>
</>,
portalContainer
)}
</>
</Droppable>
);

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();
Expand Down
Loading