Skip to content

Commit 037bb90

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 c6a9f15 commit 037bb90

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
@@ -1913,7 +1913,8 @@ describe('CdkDrag', () => {
19131913
(boolean | AddEventListenerOptions | undefined)?
19141914
]) {
19151915
document.addEventListener(...args);
1916-
}
1916+
},
1917+
createComment: (text: string) => document.createComment(text)
19171918
};
19181919
const fixture = createComponent(DraggableInDropZone, [{
19191920
provide: DOCUMENT,
@@ -4507,6 +4508,31 @@ describe('CdkDrag', () => {
45074508
});
45084509
}));
45094510

4511+
it('should not throw if its next sibling is removed while dragging', fakeAsync(() => {
4512+
const fixture = createComponent(ConnectedDropZonesWithSingleItems);
4513+
fixture.detectChanges();
4514+
4515+
const items = fixture.componentInstance.dragItems.toArray();
4516+
const item = items[0];
4517+
const nextSibling = items[1].element.nativeElement;
4518+
const extraSibling = document.createElement('div');
4519+
const targetRect = nextSibling.getBoundingClientRect();
4520+
4521+
// Manually insert an element after the node to simulate an external package.
4522+
nextSibling.parentNode!.insertBefore(extraSibling, nextSibling);
4523+
4524+
dragElementViaMouse(fixture, item.element.nativeElement,
4525+
targetRect.left + 1, targetRect.top + 1);
4526+
4527+
// Remove the extra node after the element was dropped, but before the animation is over.
4528+
extraSibling.parentNode!.removeChild(extraSibling);
4529+
4530+
expect(() => {
4531+
flush();
4532+
fixture.detectChanges();
4533+
}).not.toThrow();
4534+
}));
4535+
45104536
});
45114537

45124538
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
@@ -93,10 +93,10 @@ export class DragRef<T = any> {
9393
private _pickupPositionOnPage: Point;
9494

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

101101
/**
102102
* CSS `transform` applied to the element when it isn't being dragged. We need a
@@ -380,9 +380,10 @@ export class DragRef<T = any> {
380380
if (this.isDragging()) {
381381
// Since we move out the element to the end of the body while it's being
382382
// dragged, we have to make sure that it's removed if it gets destroyed.
383-
removeElement(this._rootElement);
383+
removeNode(this._rootElement);
384384
}
385385

386+
removeNode(this._anchor);
386387
this._destroyPreview();
387388
this._destroyPlaceholder();
388389
this._dragDropRegistry.removeDragItem(this);
@@ -400,7 +401,7 @@ export class DragRef<T = any> {
400401
this._dropContainer = undefined;
401402
this._resizeSubscription.unsubscribe();
402403
this._boundaryElement = this._rootElement = this._placeholderTemplate =
403-
this._previewTemplate = this._nextSibling = null!;
404+
this._previewTemplate = this._anchor = null!;
404405
}
405406

406407
/** Checks whether the element is currently being dragged. */
@@ -487,7 +488,7 @@ export class DragRef<T = any> {
487488
/** Destroys the preview element and its ViewRef. */
488489
private _destroyPreview() {
489490
if (this._preview) {
490-
removeElement(this._preview);
491+
removeNode(this._preview);
491492
}
492493

493494
if (this._previewRef) {
@@ -500,7 +501,7 @@ export class DragRef<T = any> {
500501
/** Destroys the placeholder element and its ViewRef. */
501502
private _destroyPlaceholder() {
502503
if (this._placeholder) {
503-
removeElement(this._placeholder);
504+
removeNode(this._placeholder);
504505
}
505506

506507
if (this._placeholderRef) {
@@ -681,19 +682,19 @@ export class DragRef<T = any> {
681682

682683
if (this._dropContainer) {
683684
const element = this._rootElement;
684-
685-
// Grab the `nextSibling` before the preview and placeholder
686-
// have been created so we don't get the preview by accident.
687-
this._nextSibling = element.nextSibling;
688-
685+
const parent = element.parentNode!;
689686
const preview = this._preview = this._createPreviewElement();
690687
const placeholder = this._placeholder = this._createPlaceholderElement();
688+
const anchor = this._anchor = this._anchor || this._document.createComment('');
689+
690+
// Insert an anchor node so that we can restore the element's position in the DOM.
691+
parent.insertBefore(anchor, element);
691692

692693
// We move the element out at the end of the body and we make it hidden, because keeping it in
693694
// place will throw off the consumer's `:last-child` selectors. We can't remove the element
694695
// from the DOM completely, because iOS will stop firing all subsequent events in the chain.
695696
element.style.display = 'none';
696-
this._document.body.appendChild(element.parentNode!.replaceChild(placeholder, element));
697+
this._document.body.appendChild(parent.replaceChild(placeholder, element));
697698
getPreviewInsertionPoint(this._document).appendChild(preview);
698699
this._dropContainer.start();
699700
}
@@ -776,12 +777,7 @@ export class DragRef<T = any> {
776777
// can throw off `NgFor` which does smart diffing and re-creates elements only when necessary,
777778
// while moving the existing elements in all other cases.
778779
this._rootElement.style.display = '';
779-
780-
if (this._nextSibling) {
781-
this._nextSibling.parentNode!.insertBefore(this._rootElement, this._nextSibling);
782-
} else {
783-
coerceElement(this._initialContainer.element).appendChild(this._rootElement);
784-
}
780+
this._anchor.parentNode!.replaceChild(this._rootElement, this._anchor);
785781

786782
this._destroyPreview();
787783
this._destroyPlaceholder();
@@ -1239,12 +1235,12 @@ function clamp(value: number, min: number, max: number) {
12391235
}
12401236

12411237
/**
1242-
* Helper to remove an element from the DOM and to do all the necessary null checks.
1243-
* @param element Element to be removed.
1238+
* Helper to remove a node from the DOM and to do all the necessary null checks.
1239+
* @param node Node to be removed.
12441240
*/
1245-
function removeElement(element: HTMLElement | null) {
1246-
if (element && element.parentNode) {
1247-
element.parentNode.removeChild(element);
1241+
function removeNode(node: Node | null) {
1242+
if (node && node.parentNode) {
1243+
node.parentNode.removeChild(node);
12481244
}
12491245
}
12501246

0 commit comments

Comments
 (0)