Skip to content

Commit dd1603a

Browse files
authored
fix: React 19 strict mode drag and drop bug (#8562)
* fix: React 19 strict mode drag and drop bug * fix test in React 16 * revert the change in react 16
1 parent 5644a71 commit dd1603a

File tree

3 files changed

+35
-42
lines changed

3 files changed

+35
-42
lines changed

packages/@react-aria/dnd/src/useDrag.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212

1313
import {AriaButtonProps} from '@react-types/button';
1414
import {DragEndEvent, DragItem, DragMoveEvent, DragPreviewRenderer, DragStartEvent, DropOperation, PressEvent, RefObject} from '@react-types/shared';
15-
import {DragEvent, HTMLAttributes, useRef, useState} from 'react';
15+
import {DragEvent, HTMLAttributes, version as ReactVersion, useEffect, useRef, useState} from 'react';
1616
import * as DragManager from './DragManager';
1717
import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './constants';
1818
import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, useDragModality, writeToDataTransfer} from './utils';
1919
// @ts-ignore
2020
import intlMessages from '../intl/*.json';
21-
import {isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners, useLayoutEffect} from '@react-aria/utils';
21+
import {isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils';
2222
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2323

2424
export interface DragOptions {
@@ -82,11 +82,11 @@ export function useDrag(options: DragOptions): DragResult {
8282
y: 0
8383
}).current;
8484
state.options = options;
85-
let isDraggingRef = useRef(false);
85+
let isDraggingRef = useRef<Element | null>(null);
8686
let [isDragging, setDraggingState] = useState(false);
87-
let setDragging = (isDragging) => {
88-
isDraggingRef.current = isDragging;
89-
setDraggingState(isDragging);
87+
let setDragging = (element: Element | null) => {
88+
isDraggingRef.current = element;
89+
setDraggingState(!!element);
9090
};
9191
let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
9292
let modalityOnPointerDown = useRef<string>(null);
@@ -186,8 +186,9 @@ export function useDrag(options: DragOptions): DragResult {
186186

187187
// Wait a frame before we set dragging to true so that the browser has time to
188188
// render the preview image before we update the element that has been dragged.
189+
let target = e.target;
189190
requestAnimationFrame(() => {
190-
setDragging(true);
191+
setDragging(target as Element);
191192
});
192193
};
193194

@@ -231,7 +232,7 @@ export function useDrag(options: DragOptions): DragResult {
231232
options.onDragEnd(event);
232233
}
233234

234-
setDragging(false);
235+
setDragging(null);
235236
removeAllGlobalListeners();
236237
setGlobalAllowedDropOperations(DROP_OPERATION.none);
237238
setGlobalDropEffect(undefined);
@@ -240,9 +241,12 @@ export function useDrag(options: DragOptions): DragResult {
240241
// If the dragged element is removed from the DOM via onDrop, onDragEnd won't fire: https://bugzilla.mozilla.org/show_bug.cgi?id=460801
241242
// In this case, we need to manually call onDragEnd on cleanup
242243

243-
useLayoutEffect(() => {
244+
useEffect(() => {
244245
return () => {
245-
if (isDraggingRef.current) {
246+
// Check that the dragged element has actually unmounted from the DOM and not a React Strict Mode false positive.
247+
// https://github.com/facebook/react/issues/29585
248+
// React 16 ran effect cleanups before removing elements from the DOM but did not have this issue.
249+
if (isDraggingRef.current && (!isDraggingRef.current.isConnected || parseInt(ReactVersion, 10) < 17)) {
246250
if (typeof state.options.onDragEnd === 'function') {
247251
let event: DragEndEvent = {
248252
type: 'dragend',
@@ -253,7 +257,7 @@ export function useDrag(options: DragOptions): DragResult {
253257
state.options.onDragEnd(event);
254258
}
255259

256-
setDragging(false);
260+
setDragging(null);
257261
setGlobalAllowedDropOperations(DROP_OPERATION.none);
258262
setGlobalDropEffect(undefined);
259263
}
@@ -285,14 +289,14 @@ export function useDrag(options: DragOptions): DragResult {
285289
? state.options.getAllowedDropOperations()
286290
: ['move', 'copy', 'link'],
287291
onDragEnd(e) {
288-
setDragging(false);
292+
setDragging(null);
289293
if (typeof state.options.onDragEnd === 'function') {
290294
state.options.onDragEnd(e);
291295
}
292296
}
293297
}, stringFormatter);
294298

295-
setDragging(true);
299+
setDragging(target);
296300
};
297301

298302
let modality = useDragModality();

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

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -228,36 +228,38 @@ describe('useDroppableCollection', () => {
228228

229229
let dataTransfer = new DataTransfer();
230230
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));
231-
act(() => jest.advanceTimersToNextTimer());
231+
act(() => jest.advanceTimersByTime(50));
232232
expect(draggable).toHaveAttribute('data-dragging', 'true');
233233

234234
fireEvent(cells[0], new DragEvent('dragenter', {dataTransfer, clientX: 30, clientY: 30}));
235-
act(() => jest.advanceTimersToNextTimer());
235+
act(() => jest.advanceTimersByTime(50));
236236
expect(scrollTop).not.toHaveBeenCalled();
237237

238238
fireEvent(cells[2], new DragEvent('dragover', {dataTransfer, clientX: 30, clientY: 100}));
239-
act(() => jest.advanceTimersToNextTimer());
239+
act(() => jest.advanceTimersByTime(50));
240240
expect(scrollTop).not.toHaveBeenCalled();
241241

242242
fireEvent(cells[4], new DragEvent('dragover', {dataTransfer, clientX: 30, clientY: 135}));
243-
act(() => jest.advanceTimersToNextTimer());
244-
expect(scrollTop).toHaveBeenCalledTimes(1);
245-
act(() => jest.advanceTimersToNextTimer());
246-
expect(scrollTop).toHaveBeenCalledTimes(2);
243+
act(() => jest.advanceTimersByTime(50));
244+
expect(scrollTop).toHaveBeenCalled();
245+
scrollTop.mockReset();
246+
act(() => jest.advanceTimersByTime(50));
247+
expect(scrollTop).toHaveBeenCalled();
247248
jest.clearAllTimers();
248249

249250
fireEvent(cells[2], new DragEvent('dragover', {dataTransfer, clientX: 30, clientY: 100}));
250-
act(() => jest.advanceTimersToNextTimer());
251-
expect(scrollTop).toHaveBeenCalledTimes(2);
251+
act(() => jest.advanceTimersByTime(50));
252+
scrollTop.mockReset();
252253

253254
fireEvent(cells[2], new DragEvent('dragover', {dataTransfer, clientX: 30, clientY: 15}));
254-
act(() => jest.advanceTimersToNextTimer());
255-
expect(scrollTop).toHaveBeenCalledTimes(3);
255+
act(() => jest.advanceTimersByTime(50));
256+
expect(scrollTop).toHaveBeenCalled();
257+
scrollTop.mockReset();
256258
jest.clearAllTimers();
257259

258260
fireEvent(cells[2], new DragEvent('dragover', {dataTransfer, clientX: 30, clientY: 30}));
259-
act(() => jest.advanceTimersToNextTimer());
260-
expect(scrollTop).toHaveBeenCalledTimes(3);
261+
act(() => jest.advanceTimersByTime(50));
262+
expect(scrollTop).not.toHaveBeenCalled();
261263
});
262264

263265
it('supports dropping on an item', async () => {

packages/@react-spectrum/list/test/ListViewDnd.test.js

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -434,12 +434,7 @@ describe('ListView', function () {
434434
expect(onDrop).toHaveBeenCalledTimes(1);
435435

436436
fireEvent(cell, new DragEvent('dragend', {dataTransfer, clientX: 1, clientY: 110}));
437-
// TODO: fix in strict mode, due to https://github.com/facebook/react/issues/29585
438-
if (isReact19) {
439-
expect(onDragEnd).toHaveBeenCalledTimes(2);
440-
} else {
441-
expect(onDragEnd).toHaveBeenCalledTimes(1);
442-
}
437+
expect(onDragEnd).toHaveBeenCalledTimes(1);
443438

444439
act(() => jest.runAllTimers());
445440

@@ -482,18 +477,10 @@ describe('ListView', function () {
482477
fireEvent(grid, new DragEvent('drop', {dataTransfer, clientX: 1, clientY: 150}));
483478
act(() => jest.runAllTimers());
484479
await act(async () => Promise.resolve());
485-
if (isReact19) {
486-
expect(onDrop).toHaveBeenCalledTimes(1);
487-
} else {
488-
expect(onDrop).toHaveBeenCalledTimes(1);
489-
}
480+
expect(onDrop).toHaveBeenCalledTimes(1);
490481

491482
fireEvent(cell, new DragEvent('dragend', {dataTransfer, clientX: 1, clientY: 150}));
492-
if (isReact19) {
493-
expect(onDragEnd).toHaveBeenCalledTimes(2);
494-
} else {
495-
expect(onDragEnd).toHaveBeenCalledTimes(1);
496-
}
483+
expect(onDrop).toHaveBeenCalledTimes(1);
497484

498485
act(() => jest.runAllTimers());
499486

0 commit comments

Comments
 (0)