Skip to content

Commit f91b314

Browse files
committed
fix(drag-drop): error if next sibling is removed after drag sequence has started
When a drag sequence is started, we save a reference to the next sibling of the drag element so that we can use it at the end of the sequence to restore the element's original DOM position. This breaks down and throws an error if the next sibling gets removed while the user is dragging. These changes make the approach a bit more robust by inserting a comment node instead. Fixes #18205.
1 parent 97a7e2b commit f91b314

File tree

2 files changed

+47
-25
lines changed

2 files changed

+47
-25
lines changed

src/cdk/drag-drop/directives/drag.spec.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1850,7 +1850,8 @@ describe('CdkDrag', () => {
18501850
},
18511851
removeEventListener: function() {
18521852
document.addEventListener.apply(document, arguments);
1853-
}
1853+
},
1854+
createComment: (text: string) => document.createComment(text)
18541855
};
18551856
const fixture = createComponent(DraggableInDropZone, [{
18561857
provide: DOCUMENT,
@@ -4403,6 +4404,31 @@ describe('CdkDrag', () => {
44034404
});
44044405
}));
44054406

4407+
it('should not throw if its next sibling is removed while dragging', fakeAsync(() => {
4408+
const fixture = createComponent(ConnectedDropZonesWithSingleItems);
4409+
fixture.detectChanges();
4410+
4411+
const items = fixture.componentInstance.dragItems.toArray();
4412+
const item = items[0];
4413+
const nextSibling = item.element.nativeElement.nextSibling!;
4414+
const extraSibling = document.createElement('div');
4415+
const targetRect = items[1].element.nativeElement.getBoundingClientRect();
4416+
4417+
// Manually insert an element after the node to simulate an external package.
4418+
nextSibling.parentNode!.insertBefore(extraSibling, nextSibling);
4419+
4420+
dragElementViaMouse(fixture, item.element.nativeElement,
4421+
targetRect.left + 1, targetRect.top + 1);
4422+
4423+
// Remove the extra node after the element was dropped, but before the animation is over.
4424+
extraSibling.parentNode!.removeChild(extraSibling);
4425+
4426+
expect(() => {
4427+
flush();
4428+
fixture.detectChanges();
4429+
}).not.toThrow();
4430+
}));
4431+
44064432
});
44074433

44084434
describe('with nested drags', () => {

src/cdk/drag-drop/drag-ref.ts

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ export class DragRef<T = any> {
8787
private _pickupPositionOnPage: Point;
8888

8989
/**
90-
* Reference to the element that comes after the draggable in the DOM, at the time
91-
* it was picked up. Used for restoring its initial position when it's dropped.
90+
* Anchor node used to save the place in the DOM where the element was
91+
* picked up so that it can be restored at the end of the drag sequence.
9292
*/
93-
private _nextSibling: Node | null;
93+
private _anchor: Comment;
9494

9595
/**
9696
* CSS `transform` applied to the element when it isn't being dragged. We need a
@@ -374,9 +374,10 @@ export class DragRef<T = any> {
374374
if (this.isDragging()) {
375375
// Since we move out the element to the end of the body while it's being
376376
// dragged, we have to make sure that it's removed if it gets destroyed.
377-
removeElement(this._rootElement);
377+
removeNode(this._rootElement);
378378
}
379379

380+
removeNode(this._anchor);
380381
this._destroyPreview();
381382
this._destroyPlaceholder();
382383
this._dragDropRegistry.removeDragItem(this);
@@ -394,7 +395,7 @@ export class DragRef<T = any> {
394395
this._dropContainer = undefined;
395396
this._resizeSubscription.unsubscribe();
396397
this._boundaryElement = this._rootElement = this._placeholderTemplate =
397-
this._previewTemplate = this._nextSibling = null!;
398+
this._previewTemplate = this._anchor = null!;
398399
}
399400

400401
/** Checks whether the element is currently being dragged. */
@@ -481,7 +482,7 @@ export class DragRef<T = any> {
481482
/** Destroys the preview element and its ViewRef. */
482483
private _destroyPreview() {
483484
if (this._preview) {
484-
removeElement(this._preview);
485+
removeNode(this._preview);
485486
}
486487

487488
if (this._previewRef) {
@@ -494,7 +495,7 @@ export class DragRef<T = any> {
494495
/** Destroys the placeholder element and its ViewRef. */
495496
private _destroyPlaceholder() {
496497
if (this._placeholder) {
497-
removeElement(this._placeholder);
498+
removeNode(this._placeholder);
498499
}
499500

500501
if (this._placeholderRef) {
@@ -672,19 +673,19 @@ export class DragRef<T = any> {
672673

673674
if (this._dropContainer) {
674675
const element = this._rootElement;
675-
676-
// Grab the `nextSibling` before the preview and placeholder
677-
// have been created so we don't get the preview by accident.
678-
this._nextSibling = element.nextSibling;
679-
676+
const parent = element.parentNode!;
680677
const preview = this._preview = this._createPreviewElement();
681678
const placeholder = this._placeholder = this._createPlaceholderElement();
679+
const anchor = this._anchor = this._anchor || this._document.createComment('');
680+
681+
// Insert an anchor node so that we can restore the element's position in the DOM.
682+
parent.insertBefore(anchor, element);
682683

683684
// We move the element out at the end of the body and we make it hidden, because keeping it in
684685
// place will throw off the consumer's `:last-child` selectors. We can't remove the element
685686
// from the DOM completely, because iOS will stop firing all subsequent events in the chain.
686687
element.style.display = 'none';
687-
this._document.body.appendChild(element.parentNode!.replaceChild(placeholder, element));
688+
this._document.body.appendChild(parent.replaceChild(placeholder, element));
688689
getPreviewInsertionPoint(this._document).appendChild(preview);
689690
this._dropContainer.start();
690691
}
@@ -767,12 +768,7 @@ export class DragRef<T = any> {
767768
// can throw off `NgFor` which does smart diffing and re-creates elements only when necessary,
768769
// while moving the existing elements in all other cases.
769770
this._rootElement.style.display = '';
770-
771-
if (this._nextSibling) {
772-
this._nextSibling.parentNode!.insertBefore(this._rootElement, this._nextSibling);
773-
} else {
774-
coerceElement(this._initialContainer.element).appendChild(this._rootElement);
775-
}
771+
this._anchor.parentNode!.replaceChild(this._rootElement, this._anchor);
776772

777773
this._destroyPreview();
778774
this._destroyPlaceholder();
@@ -1236,12 +1232,12 @@ function clamp(value: number, min: number, max: number) {
12361232
}
12371233

12381234
/**
1239-
* Helper to remove an element from the DOM and to do all the necessary null checks.
1240-
* @param element Element to be removed.
1235+
* Helper to remove a node from the DOM and to do all the necessary null checks.
1236+
* @param node Node to be removed.
12411237
*/
1242-
function removeElement(element: HTMLElement | null) {
1243-
if (element && element.parentNode) {
1244-
element.parentNode.removeChild(element);
1238+
function removeNode(node: Node | null) {
1239+
if (node && node.parentNode) {
1240+
node.parentNode.removeChild(node);
12451241
}
12461242
}
12471243

0 commit comments

Comments
 (0)