Skip to content

Commit 3e9dac7

Browse files
authored
fix(dnd): prevent repeated useDrop enter/exit events for portal children (adobe#9844)
1 parent 93df536 commit 3e9dac7

File tree

2 files changed

+58
-4
lines changed

2 files changed

+58
-4
lines changed

packages/react-aria/src/dnd/useDrop.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,16 @@ export function useDrop(options: DropOptions): DropResult {
236236
// events will never be fired for these. This can happen, for example, with drop
237237
// indicators between items, which disappear when the drop target changes.
238238

239-
state.dragOverElements.delete(getEventTarget(e));
240-
for (let element of state.dragOverElements) {
241-
if (!nodeContains(e.currentTarget, element)) {
242-
state.dragOverElements.delete(element);
239+
let target = getEventTarget(e);
240+
state.dragOverElements.delete(target);
241+
242+
// Only remove stale elements when leaving the drop target itself.
243+
// Avoids issues with portal children bubbling dragleave events through the React tree.
244+
if (target === e.currentTarget) {
245+
for (let element of state.dragOverElements) {
246+
if (!nodeContains(e.currentTarget, element)) {
247+
state.dragOverElements.delete(element);
248+
}
243249
}
244250
}
245251

packages/react-aria/test/dnd/dnd.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {DataTransfer, DataTransferItem, DragEvent, FileSystemDirectoryEntry, Fil
1818
import {Draggable, Droppable} from './examples';
1919
import {DragTypes} from '../../src/dnd/utils';
2020
import React, {useEffect} from 'react';
21+
import ReactDOM from 'react-dom';
2122
import userEvent from '@testing-library/user-event';
2223

2324
function pointerEvent(type, opts) {
@@ -397,6 +398,53 @@ describe('useDrag and useDrop', function () {
397398
expect(onDropExit).toHaveBeenCalledTimes(1);
398399
});
399400

401+
it('does not fire onDropEnter and onDropExit repeatedly for portal children', () => {
402+
let onDropEnter = jest.fn();
403+
let onDropExit = jest.fn();
404+
let portalContainer = document.createElement('div');
405+
document.body.appendChild(portalContainer);
406+
407+
try {
408+
let tree = render(
409+
<Droppable onDropEnter={onDropEnter} onDropExit={onDropExit}>
410+
<>
411+
<div>Drop here</div>
412+
{ReactDOM.createPortal(
413+
<>
414+
<div>Portal child 1</div>
415+
<div>Portal child 2</div>
416+
</>,
417+
portalContainer
418+
)}
419+
</>
420+
</Droppable>
421+
);
422+
423+
let portalChild1 = tree.getByText('Portal child 1');
424+
let portalChild2 = tree.getByText('Portal child 2');
425+
426+
let dataTransfer = new DataTransfer();
427+
fireEvent(portalChild1, new DragEvent('dragenter', {dataTransfer, clientX: 1, clientY: 1}));
428+
expect(onDropEnter).toHaveBeenCalledTimes(1);
429+
expect(onDropExit).not.toHaveBeenCalled();
430+
431+
fireEvent(portalChild2, new DragEvent('dragenter', {dataTransfer, clientX: 1, clientY: 1, relatedTarget: portalChild1}));
432+
fireEvent(portalChild1, new DragEvent('dragleave', {dataTransfer, clientX: 1, clientY: 1, relatedTarget: portalChild2}));
433+
expect(onDropEnter).toHaveBeenCalledTimes(1);
434+
expect(onDropExit).not.toHaveBeenCalled();
435+
436+
fireEvent(portalChild1, new DragEvent('dragenter', {dataTransfer, clientX: 1, clientY: 1, relatedTarget: portalChild2}));
437+
fireEvent(portalChild2, new DragEvent('dragleave', {dataTransfer, clientX: 1, clientY: 1, relatedTarget: portalChild1}));
438+
expect(onDropEnter).toHaveBeenCalledTimes(1);
439+
expect(onDropExit).not.toHaveBeenCalled();
440+
441+
fireEvent(portalChild1, new DragEvent('dragleave', {dataTransfer, clientX: 1, clientY: 1}));
442+
expect(onDropExit).toHaveBeenCalledTimes(1);
443+
} finally {
444+
portalContainer.remove();
445+
}
446+
});
447+
400448
describe('nested drag targets', () => {
401449
let onDragStartParent = jest.fn();
402450
let onDragMoveParent = jest.fn();

0 commit comments

Comments
 (0)