From 4eada7557753948b8fb0d6b90139cb7046b177f0 Mon Sep 17 00:00:00 2001 From: Kebechet Date: Wed, 28 Oct 2026 14:43:18 +0100 Subject: [PATCH] Ignore a fallback drag whose element was removed from the DOM When the app removes the dragged element while a fallback (touch or forceFallback) drag is in progress, _emulateDragOver throws "Cannot read properties of null (reading 'Sortable...')" every 50 ms until the pointer is released, and nearestEmptyInsertDetectEvent puts the removed element back into a nearby empty list, so the drop reports an add/remove for an element the app no longer has. Both now skip the drag once dragEl has no parent. The drop that follows ends it as before. https://github.com/SortableJS/Sortable/issues/2307 --- src/Sortable.js | 6 +++-- tests/Sortable.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Sortable.js b/src/Sortable.js index 72f571c82..818d82a6d 100644 --- a/src/Sortable.js +++ b/src/Sortable.js @@ -313,7 +313,8 @@ if (documentExists && !ChromeForAndroid) { } let nearestEmptyInsertDetectEvent = function(evt) { - if (dragEl) { + // A dragEl the app removed from the DOM mid-drag must not be inserted back: https://github.com/SortableJS/Sortable/issues/2307 + if (dragEl && dragEl.parentNode) { evt = evt.touches ? evt.touches[0] : evt; let nearest = _detectNearestEmptySortable(evt.clientX, evt.clientY); @@ -759,7 +760,8 @@ Sortable.prototype = /** @lends Sortable.prototype */ { }, _emulateDragOver: function () { - if (touchEvt) { + // Nothing to sort once the app has removed dragEl from the DOM mid-drag: https://github.com/SortableJS/Sortable/issues/2307 + if (touchEvt && dragEl.parentNode) { this._lastX = touchEvt.clientX; this._lastY = touchEvt.clientY; diff --git a/tests/Sortable.test.js b/tests/Sortable.test.js index 59ce11f46..2fdf30c8a 100644 --- a/tests/Sortable.test.js +++ b/tests/Sortable.test.js @@ -384,3 +384,53 @@ test('Do not insert into empty list if outside emptyInsertThreshold', async brow }) .expect(dragStartPosition.innerText).eql(dragEl.innerText); }); + + +fixture `Dragged element removed` + .page `./empty-list.html`; + +test('Ignore a fallback drag whose element was removed from the DOM', async browser => { + const result = await browser.eval(() => new Promise(resolve => { + const list1 = document.getElementById('list1'); + const list2 = document.getElementById('list2'); + + const errors = []; + window.addEventListener('error', e => errors.push(e.message)); + + const dragEl = list1.children[1]; + const dragRect = dragEl.getBoundingClientRect(); + const list2Rect = list2.getBoundingClientRect(); + const fire = (type, target, x, y) => { + const init = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: 'touch', isPrimary: true }; + target.dispatchEvent(new PointerEvent(type, init)); + if (type === 'pointermove') { + document.dispatchEvent(new MouseEvent('mousemove', init)); + } + }; + + const x = dragRect.left + 10; + const y = dragRect.top + 10; + fire('pointerdown', dragEl, x, y); + fire('pointermove', document, x, y + 5); + setTimeout(() => { + fire('pointermove', document, x, y + 10); + const isDragging = !!Sortable.active; + // The app removes the item while it is being dragged, e.g. a re-render after a data change + dragEl.parentNode.removeChild(dragEl); + setTimeout(() => { + fire('pointermove', document, list2Rect.left + 1, list2Rect.top + 1); + setTimeout(() => { + const isReinserted = document.contains(dragEl); + fire('pointerup', document, list2Rect.left + 1, list2Rect.top + 1); + resolve({ isDragging, errors, isReinserted, list2ChildCount: list2.children.length }); + }, 200); + }, 200); + }, 100); + })); + + await browser + .expect(result.isDragging).ok() + .expect(result.errors).eql([]) + .expect(result.isReinserted).notOk() + .expect(result.list2ChildCount).eql(0); +});