Skip to content

Commit 2446318

Browse files
crisbetojosephperrott
authored andcommitted
fix(drag-drop): throwing off NgFor differ (#12393)
Reworks the drag&drop not to move the element around in the DOM anymore once it's dropped. Moving the element while it's inside an `NgFor` can be dangerous, because Angular may decide to move the element itself, rather than recreate it, in which case the DOM order will be wrong. Fixes #12388.
1 parent f3f7b5a commit 2446318

File tree

2 files changed

+94
-36
lines changed

2 files changed

+94
-36
lines changed

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

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,29 @@ describe('CdkDrag', () => {
340340
.toEqual(['One', 'Two', 'Zero', 'Three']);
341341
}));
342342

343+
it('should not move the original element from its initial DOM position', fakeAsync(() => {
344+
const fixture = createComponent(DraggableInDropZone);
345+
fixture.detectChanges();
346+
const root = fixture.nativeElement as HTMLElement;
347+
let dragElements = Array.from(root.querySelectorAll('.cdk-drag'));
348+
349+
expect(dragElements.map(el => el.textContent)).toEqual(['Zero', 'One', 'Two', 'Three']);
350+
351+
// Stub out the original call so the list doesn't get re-rendered.
352+
// We're testing the DOM order explicitly.
353+
fixture.componentInstance.droppedSpy.and.callFake(() => {});
354+
355+
const thirdItemRect = dragElements[2].getBoundingClientRect();
356+
357+
dragElementViaMouse(fixture, fixture.componentInstance.dragItems.first.element.nativeElement,
358+
thirdItemRect.left + 1, thirdItemRect.top + 1);
359+
flush();
360+
fixture.detectChanges();
361+
362+
dragElements = Array.from(root.querySelectorAll('.cdk-drag'));
363+
expect(dragElements.map(el => el.textContent)).toEqual(['Zero', 'One', 'Two', 'Three']);
364+
}));
365+
343366
it('should dispatch the `dropped` event in a horizontal drop zone', fakeAsync(() => {
344367
const fixture = createComponent(DraggableInHorizontalDropZone);
345368
fixture.detectChanges();
@@ -677,14 +700,14 @@ describe('CdkDrag', () => {
677700
const initialRect = item.element.nativeElement.getBoundingClientRect();
678701
const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect();
679702

680-
expect(dropZones[0].contains(item.element.nativeElement))
681-
.toBe(true, 'Expected placeholder to be inside the first container.');
682703
dispatchMouseEvent(item.element.nativeElement, 'mousedown');
683704
fixture.detectChanges();
684705

685706
const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!;
686707

687708
expect(placeholder).toBeTruthy();
709+
expect(dropZones[0].contains(placeholder))
710+
.toBe(true, 'Expected placeholder to be inside the first container.');
688711

689712
dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1);
690713
fixture.detectChanges();
@@ -708,18 +731,25 @@ describe('CdkDrag', () => {
708731
const fixture = createComponent(ConnectedDropZones);
709732
fixture.detectChanges();
710733

711-
const groups = fixture.componentInstance.groupedDragItems;
734+
const groups = fixture.componentInstance.groupedDragItems.slice();
712735
const element = groups[0][1].element.nativeElement;
713-
const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement);
736+
const dropInstances = fixture.componentInstance.dropInstances.toArray();
714737
const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect();
715738

716-
expect(dropZones[0].contains(element)).toBe(true);
717-
718739
dragElementViaMouse(fixture, element, targetRect.left + 1, targetRect.top + 1);
719740
flush();
720741
fixture.detectChanges();
721742

722-
expect(dropZones[1].contains(element)).toBe(true);
743+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
744+
745+
expect(event).toBeTruthy();
746+
expect(event).toEqual({
747+
previousIndex: 1,
748+
currentIndex: 3,
749+
item: groups[0][1],
750+
container: dropInstances[1],
751+
previousContainer: dropInstances[0]
752+
});
723753
}));
724754

725755
it('should not be able to transfer an item into a container that is not in `connectedTo`',
@@ -730,18 +760,25 @@ describe('CdkDrag', () => {
730760
fixture.componentInstance.dropInstances.forEach(d => d.connectedTo = []);
731761
fixture.detectChanges();
732762

733-
const groups = fixture.componentInstance.groupedDragItems;
763+
const groups = fixture.componentInstance.groupedDragItems.slice();
734764
const element = groups[0][1].element.nativeElement;
735-
const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement);
765+
const dropInstances = fixture.componentInstance.dropInstances.toArray();
736766
const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect();
737767

738-
expect(dropZones[0].contains(element)).toBe(true);
739-
740768
dragElementViaMouse(fixture, element, targetRect.left + 1, targetRect.top + 1);
741769
flush();
742770
fixture.detectChanges();
743771

744-
expect(dropZones[0].contains(element)).toBe(true);
772+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
773+
774+
expect(event).toBeTruthy();
775+
expect(event).toEqual({
776+
previousIndex: 1,
777+
currentIndex: 1,
778+
item: groups[0][1],
779+
container: dropInstances[0],
780+
previousContainer: dropInstances[0]
781+
});
745782
}));
746783

747784

@@ -751,20 +788,17 @@ describe('CdkDrag', () => {
751788

752789
const groups = fixture.componentInstance.groupedDragItems;
753790
const element = groups[0][1].element.nativeElement;
754-
const dropZones = fixture.componentInstance.dropInstances.map(d => d.element.nativeElement);
755-
const targetRect = dropZones[1].getBoundingClientRect();
756-
757-
expect(dropZones[0].contains(element)).toBe(true);
791+
const dropZone = fixture.componentInstance.dropInstances.toArray()[1].element.nativeElement;
792+
const targetRect = dropZone.getBoundingClientRect();
758793

759794
// Drag the element into the drop zone and move it to the top.
760795
[1, -1].forEach(offset => {
761796
dragElementViaMouse(fixture, element, targetRect.left + offset, targetRect.top + offset);
762797
flush();
763798
fixture.detectChanges();
764-
expect(dropZones[1].contains(element)).toBe(true);
765799
});
766800

767-
assertDownwardSorting(fixture, Array.from(dropZones[1].querySelectorAll('.cdk-drag')));
801+
assertDownwardSorting(fixture, Array.from(dropZone.querySelectorAll('.cdk-drag')));
768802
}));
769803

770804
it('should be able to return the last item inside its initial container', fakeAsync(() => {
@@ -780,15 +814,16 @@ describe('CdkDrag', () => {
780814
const initialRect = item.element.nativeElement.getBoundingClientRect();
781815
const targetRect = groups[1][0].element.nativeElement.getBoundingClientRect();
782816

783-
expect(dropZones[0].contains(item.element.nativeElement))
784-
.toBe(true, 'Expected placeholder to be inside the first container.');
785817
dispatchMouseEvent(item.element.nativeElement, 'mousedown');
786818
fixture.detectChanges();
787819

788820
const placeholder = dropZones[0].querySelector('.cdk-drag-placeholder')!;
789821

790822
expect(placeholder).toBeTruthy();
791823

824+
expect(dropZones[0].contains(placeholder))
825+
.toBe(true, 'Expected placeholder to be inside the first container.');
826+
792827
dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1);
793828
fixture.detectChanges();
794829

@@ -820,25 +855,32 @@ describe('CdkDrag', () => {
820855
const fixture = createComponent(ConnectedDropZones);
821856
fixture.detectChanges();
822857

823-
const dropZones = fixture.componentInstance.dropInstances.toArray();
858+
const dropInstances = fixture.componentInstance.dropInstances.toArray();
824859

825-
dropZones[0].id = 'todo';
826-
dropZones[1].id = 'done';
827-
dropZones[0].connectedTo = ['done'];
828-
dropZones[1].connectedTo = ['todo'];
860+
dropInstances[0].id = 'todo';
861+
dropInstances[1].id = 'done';
862+
dropInstances[0].connectedTo = ['done'];
863+
dropInstances[1].connectedTo = ['todo'];
829864
fixture.detectChanges();
830865

831866
const groups = fixture.componentInstance.groupedDragItems;
832867
const element = groups[0][1].element.nativeElement;
833868
const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect();
834869

835-
expect(dropZones[0].element.nativeElement.contains(element)).toBe(true);
836-
837870
dragElementViaMouse(fixture, element, targetRect.left + 1, targetRect.top + 1);
838871
flush();
839872
fixture.detectChanges();
840873

841-
expect(dropZones[1].element.nativeElement.contains(element)).toBe(true);
874+
const event = fixture.componentInstance.droppedSpy.calls.mostRecent().args[0];
875+
876+
expect(event).toBeTruthy();
877+
expect(event).toEqual({
878+
previousIndex: 1,
879+
currentIndex: 3,
880+
item: groups[0][1],
881+
container: dropInstances[1],
882+
previousContainer: dropInstances[0]
883+
});
842884
}));
843885

844886
});

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ export class CdkDrag<T = any> implements OnDestroy {
7171
/** Coordinates on the page at which the user picked up the element. */
7272
private _pickupPositionOnPage: Point;
7373

74+
/**
75+
* Reference to the element that comes after the draggable in the DOM, at the time
76+
* it was picked up. Used for restoring its initial position when it's dropped.
77+
*/
78+
private _nextSibling: Node | null;
79+
7480
/**
7581
* CSS `transform` applied to the element when it isn't being dragged. We need a
7682
* passive transform in order for the dragged element to retain its new position
@@ -158,6 +164,7 @@ export class CdkDrag<T = any> implements OnDestroy {
158164
this._removeElement(this.element.nativeElement);
159165
}
160166

167+
this._nextSibling = null;
161168
this._dragDropRegistry.remove(this);
162169
this._destroyed.next();
163170
this._destroyed.complete();
@@ -220,6 +227,7 @@ export class CdkDrag<T = any> implements OnDestroy {
220227
// place will throw off the consumer's `:last-child` selectors. We can't remove the element
221228
// from the DOM completely, because iOS will stop firing all subsequent events in the chain.
222229
element.style.display = 'none';
230+
this._nextSibling = element.nextSibling;
223231
this._document.body.appendChild(element.parentNode!.replaceChild(placeholder, element));
224232
this._document.body.appendChild(preview);
225233
this.dropContainer.start();
@@ -271,15 +279,25 @@ export class CdkDrag<T = any> implements OnDestroy {
271279

272280
/** Cleans up the DOM artifacts that were added to facilitate the element being dragged. */
273281
private _cleanupDragArtifacts() {
282+
const currentIndex = this._getElementIndexInDom(this._placeholder);
283+
284+
// Restore the element's visibility and insert it at its old position in the DOM.
285+
// It's important that we maintain the position, because moving the element around in the DOM
286+
// can throw off `NgFor` which does smart diffing and re-creates elements only when necessary,
287+
// while moving the existing elements in all other cases.
288+
this.element.nativeElement.style.display = '';
289+
290+
if (this._nextSibling) {
291+
this._nextSibling.parentNode!.insertBefore(this.element.nativeElement, this._nextSibling);
292+
} else {
293+
this._placeholder.parentNode!.appendChild(this.element.nativeElement);
294+
}
295+
274296
this._destroyPreview();
275-
this._placeholder.parentNode!.insertBefore(this.element.nativeElement, this._placeholder);
276297
this._destroyPlaceholder();
277-
this.element.nativeElement.style.display = '';
278298

279299
// Re-enter the NgZone since we bound `document` events on the outside.
280300
this._ngZone.run(() => {
281-
const currentIndex = this._getElementIndexInDom();
282-
283301
this.ended.emit({source: this});
284302
this.dropped.emit({
285303
item: this,
@@ -368,14 +386,12 @@ export class CdkDrag<T = any> implements OnDestroy {
368386
return placeholder;
369387
}
370388

371-
/** Gets the index of the dragable element, based on its index in the DOM. */
372-
private _getElementIndexInDom(): number {
389+
/** Gets the index of an element, based on its index in the DOM. */
390+
private _getElementIndexInDom(element: HTMLElement): number {
373391
// Note: we may be able to figure this in memory while sorting, but doing so won't be very
374392
// reliable when transferring between containers, because the new container doesn't have
375393
// the proper indices yet. Also this will work better for the case where the consumer
376394
// isn't using an `ngFor` to render the list.
377-
const element = this.element.nativeElement;
378-
379395
if (!element.parentElement) {
380396
return -1;
381397
}

0 commit comments

Comments
 (0)