Is this a regression?
The previous version in which this bug was not present was
Any version, when running with zone.js. The bug only appears with provideZonelessChangeDetection()
Description
DropListRef.drop() resets the list before it emits dropped:
// drag-drop.mjs, DropListRef drop(item, currentIndex, previousIndex, ...) { this._reset(); this.dropped.next({ item, currentIndex, previousIndex, ... }); }
and DragRef._cleanupDragArtifacts() has already put the dragged root element back at its original DOM position before that:
_cleanupDragArtifacts(event) { toggleVisibility(this._rootElement, true, dragImportantProperties); this._marker.parentNode.replaceChild(this._rootElement, this._marker); this._destroyPreview(); this._destroyPlaceholder(); ... this._ngZone.run(() => { ... this.dropped.next({ item: this, currentIndex, previousIndex: this._initialIndex, ... }); }); }
So at the moment the application's (cdkDropListDropped) handler runs, the list is in the pre-drop order: the sibling transforms are gone and the dragged element sits where it started. The application then reorders its model and the framework re-renders.
CdkDropList relies on markForCheck() alone to get that render out:
ref.dropped.subscribe(dropEvent => { this.dropped.emit({ ... }); this._changeDetectorRef.markForCheck(); });
Under zone.js this is enough and there is no bug: leaving the this._ngZone.run(...) above drains the microtask queue, onMicrotaskEmpty fires, and ApplicationRef.tick() runs synchronously before control returns to the browser. The reset and the re-render land in the same task, so the intermediate state is never painted.
Under provideZonelessChangeDetection() that guarantee is gone. NgZone.run is a pass-through and markForCheck() only notifies the scheduler, which ticks in a later task (the rAF/setTimeout race). Anything the browser paints in between shows the old order, so the row that was just dropped is seen jumping back to where it came from and then forward again.
This is the same class of bug as #29174 (cdk/scrolling virtual scroll flickering in zoneless), fixed in #31316 by making sure the DOM write and the render happen "within the same application tick". cdk/drag-drop never got the equivalent treatment: there is no ApplicationRef or ChangeDetectionScheduler reference anywhere in drag-drop.mjs, in 22.1.5 or in 22.2.0-rc.0.
It is easy to miss because the recommended .cdk-drag-animating { transition: transform 250ms } partly hides it: the animation delays _cleanupDragArtifacts(), so the blink happens at the end of the drop animation rather than at pointerup. It gets much more noticeable on slower machines, where the change-detection pass is more likely to miss the current frame.
Reproduction
StackBlitz link: https://stackblitz.com/edit/components-issue-starter-ynvh4thy
The starter needs two edits:
In main.ts, use zoneless change detection: bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] }) (and drop zone.js from polyfills).
Use the drag & drop overview example verbatim -- a cdkDropList with ~20 cdkDrag rows and drop(event) { moveItemInArray(this.items, event.previousIndex, event.currentIndex); }, plus the documented .cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }.
Steps to reproduce:
Throttle the CPU in DevTools (4x or 6x slowdown) to widen the window.
Drag the first row down a few positions and release.
Watch the row at the end of the drop animation: it appears back at its original position for a frame or more, then jumps to the drop position.
To see it without relying on the eye, paste this in the console and do the drag. It arms a MutationObserver on the list container and reports one entry per batch of DOM mutations:
const ul = document.querySelector('.example-list'); const log = []; const obs = new MutationObserver(m => log.push({ n: m.length, first: ul.children[0].textContent.trim() })); addEventListener('pointerup', () => { obs.observe(ul, { childList: true }); setTimeout(() => { obs.disconnect(); console.table(log); }, 1000); }, { once: true, capture: true });
With zone.js you get one batch. With zoneless you get two, in two separate tasks: the first is the CDK reset and reports the pre-drop order, the second is Angular's re-render with the new one.
Expected Behavior
The reordered list is rendered in the same task in which DropListRef resets it, so the pre-drop order is never painted -- matching what zone.js apps get today.
Actual Behavior
The CDK reset and the application's re-render land in two different tasks. In between, the browser can paint the pre-drop order, so the dropped item visibly jumps back to its original position before settling at the drop position.
Environment
Angular: 22.1.4 (zoneless, provideZonelessChangeDetection())
CDK/Material: 22.1.5 (also verified unchanged in 22.2.0-rc.0)
Browser(s): Chrome 153.0.8010.36 (Official Build) snap (x86_64)
Operating System (e.g. Windows, macOS, Ubuntu): macOS
Is this a regression?
The previous version in which this bug was not present was
Any version, when running with zone.js. The bug only appears with provideZonelessChangeDetection()
Description
DropListRef.drop() resets the list before it emits dropped:
// drag-drop.mjs, DropListRef drop(item, currentIndex, previousIndex, ...) { this._reset(); this.dropped.next({ item, currentIndex, previousIndex, ... }); }and DragRef._cleanupDragArtifacts() has already put the dragged root element back at its original DOM position before that:
_cleanupDragArtifacts(event) { toggleVisibility(this._rootElement, true, dragImportantProperties); this._marker.parentNode.replaceChild(this._rootElement, this._marker); this._destroyPreview(); this._destroyPlaceholder(); ... this._ngZone.run(() => { ... this.dropped.next({ item: this, currentIndex, previousIndex: this._initialIndex, ... }); }); }So at the moment the application's (cdkDropListDropped) handler runs, the list is in the pre-drop order: the sibling transforms are gone and the dragged element sits where it started. The application then reorders its model and the framework re-renders.
CdkDropList relies on markForCheck() alone to get that render out:
ref.dropped.subscribe(dropEvent => { this.dropped.emit({ ... }); this._changeDetectorRef.markForCheck(); });Under zone.js this is enough and there is no bug: leaving the this._ngZone.run(...) above drains the microtask queue, onMicrotaskEmpty fires, and ApplicationRef.tick() runs synchronously before control returns to the browser. The reset and the re-render land in the same task, so the intermediate state is never painted.
Under provideZonelessChangeDetection() that guarantee is gone. NgZone.run is a pass-through and markForCheck() only notifies the scheduler, which ticks in a later task (the rAF/setTimeout race). Anything the browser paints in between shows the old order, so the row that was just dropped is seen jumping back to where it came from and then forward again.
This is the same class of bug as #29174 (cdk/scrolling virtual scroll flickering in zoneless), fixed in #31316 by making sure the DOM write and the render happen "within the same application tick". cdk/drag-drop never got the equivalent treatment: there is no ApplicationRef or ChangeDetectionScheduler reference anywhere in drag-drop.mjs, in 22.1.5 or in 22.2.0-rc.0.
It is easy to miss because the recommended .cdk-drag-animating { transition: transform 250ms } partly hides it: the animation delays _cleanupDragArtifacts(), so the blink happens at the end of the drop animation rather than at pointerup. It gets much more noticeable on slower machines, where the change-detection pass is more likely to miss the current frame.
Reproduction
StackBlitz link: https://stackblitz.com/edit/components-issue-starter-ynvh4thy
The starter needs two edits:
In main.ts, use zoneless change detection: bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] }) (and drop zone.js from polyfills).
Use the drag & drop overview example verbatim -- a cdkDropList with ~20 cdkDrag rows and drop(event) { moveItemInArray(this.items, event.previousIndex, event.currentIndex); }, plus the documented .cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }.
Steps to reproduce:
Throttle the CPU in DevTools (4x or 6x slowdown) to widen the window.
Drag the first row down a few positions and release.
Watch the row at the end of the drop animation: it appears back at its original position for a frame or more, then jumps to the drop position.
To see it without relying on the eye, paste this in the console and do the drag. It arms a MutationObserver on the list container and reports one entry per batch of DOM mutations:
const ul = document.querySelector('.example-list'); const log = []; const obs = new MutationObserver(m => log.push({ n: m.length, first: ul.children[0].textContent.trim() })); addEventListener('pointerup', () => { obs.observe(ul, { childList: true }); setTimeout(() => { obs.disconnect(); console.table(log); }, 1000); }, { once: true, capture: true });With zone.js you get one batch. With zoneless you get two, in two separate tasks: the first is the CDK reset and reports the pre-drop order, the second is Angular's re-render with the new one.
Expected Behavior
The reordered list is rendered in the same task in which DropListRef resets it, so the pre-drop order is never painted -- matching what zone.js apps get today.
Actual Behavior
The CDK reset and the application's re-render land in two different tasks. In between, the browser can paint the pre-drop order, so the dropped item visibly jumps back to its original position before settling at the drop position.
Environment
Angular: 22.1.4 (zoneless, provideZonelessChangeDetection())
CDK/Material: 22.1.5 (also verified unchanged in 22.2.0-rc.0)
Browser(s): Chrome 153.0.8010.36 (Official Build) snap (x86_64)
Operating System (e.g. Windows, macOS, Ubuntu): macOS