Cancel drag activation when the drag source unmounts mid-gesture (#23816)
## Summary Follow-up to the Sentry error [TWENTY-FRONT-HJ8](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-HJ8) (`Cannot start a drag operation without a drag source`), seen on a dashboard page and discussed in #23752. A pointer drag only activates once the pointer travels past the activation constraints (distance/delay). dnd-kit's `PointerSensor` captures the pressed draggable on pointerdown, and when the constraint is satisfied it starts the drag by resolving that draggable's id in the registry. If a re-render unregistered it in between, the lookup fails and `manager.actions.start()` throws. That window is real in our UI: virtualized table rows remount under new per-instance sortable ids, and widgets/tabs remount while a page loads. The breadcrumbs of the Sentry event show the gesture straddling a navigation onto a loading dashboard, with the error firing on the activating `pointermove` 190ms later. ## Fix `PointerSensorWithSourceGuard` extends `PointerSensor` and checks the registry before starting: if the pressed draggable is gone, it cancels the gesture through the sensor's own cancel path (same one dnd-kit wires to activation aborts) instead of throwing. There is nothing left to drag at that point, so cancel is the correct outcome. `DND_KIT_SENSORS` now uses it, which covers every dnd surface. Unit tests pin the behavior with real dnd-kit internals: the base sensor throws in this scenario (documents why the guard exists, and breaks if upstream fixes it so we can remove the subclass), the guard cancels and leaves the operation idle, and a still-registered source starts normally. Fixes TWENTY-FRONT-HJ8 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23816?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+3
-2
@@ -1,10 +1,11 @@
|
||||
import { KeyboardSensor, PointerSensor } from '@dnd-kit/react';
|
||||
import { KeyboardSensor } from '@dnd-kit/react';
|
||||
|
||||
import { PointerSensorWithSourceGuard } from '@/ui/utilities/drag-and-drop/sensors/PointerSensorWithSourceGuard';
|
||||
import { getDragActivationConstraints } from '@/ui/utilities/drag-and-drop/utils/getDragActivationConstraints';
|
||||
import { shouldPreventDragActivation } from '@/ui/utilities/drag-and-drop/utils/shouldPreventDragActivation';
|
||||
|
||||
export const DND_KIT_SENSORS = [
|
||||
PointerSensor.configure({
|
||||
PointerSensorWithSourceGuard.configure({
|
||||
activationConstraints: getDragActivationConstraints,
|
||||
preventActivation: shouldPreventDragActivation,
|
||||
}),
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type Draggable } from '@dnd-kit/dom';
|
||||
import { PointerSensor } from '@dnd-kit/react';
|
||||
|
||||
// A drag only activates once the pointer travels past the activation
|
||||
// constraints, so a re-render can unregister the pressed draggable between
|
||||
// pointerdown and activation: virtualized rows remounting under new sortable
|
||||
// ids, a widget or tab remounting while a page loads. The base sensor then
|
||||
// throws "Cannot start a drag operation without a drag source"; there is
|
||||
// nothing left to drag, so the gesture is canceled instead.
|
||||
export class PointerSensorWithSourceGuard extends PointerSensor {
|
||||
protected handleStart(source: Draggable, event: PointerEvent): void {
|
||||
if (!this.manager.registry.draggables.has(source.id)) {
|
||||
this.handleCancel(event);
|
||||
return;
|
||||
}
|
||||
|
||||
super.handleStart(source, event);
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { DragDropManager, Draggable } from '@dnd-kit/dom';
|
||||
import { PointerSensor } from '@dnd-kit/react';
|
||||
|
||||
import { PointerSensorWithSourceGuard } from '@/ui/utilities/drag-and-drop/sensors/PointerSensorWithSourceGuard';
|
||||
|
||||
const INITIAL_COORDINATES = { x: 0, y: 0 };
|
||||
|
||||
class TestablePointerSensorWithSourceGuard extends PointerSensorWithSourceGuard {
|
||||
public wasCanceled = false;
|
||||
|
||||
public startFromActivation(source: Draggable, event: PointerEvent): void {
|
||||
this.initialCoordinates = INITIAL_COORDINATES;
|
||||
this.handleStart(source, event);
|
||||
}
|
||||
|
||||
protected handleCancel(event: Event): void {
|
||||
this.wasCanceled = true;
|
||||
super.handleCancel(event);
|
||||
}
|
||||
}
|
||||
|
||||
class TestablePointerSensor extends PointerSensor {
|
||||
public startFromActivation(source: Draggable, event: PointerEvent): void {
|
||||
this.initialCoordinates = INITIAL_COORDINATES;
|
||||
this.handleStart(source, event);
|
||||
}
|
||||
}
|
||||
|
||||
const createActivationEvent = () => new Event('pointermove') as PointerEvent;
|
||||
|
||||
describe('PointerSensorWithSourceGuard', () => {
|
||||
let manager: DragDropManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new DragDropManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.destroy();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createUnregisteredDraggable = () =>
|
||||
new Draggable(
|
||||
{
|
||||
id: 'pressed-draggable',
|
||||
element: document.createElement('div'),
|
||||
register: false,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
it('documents that the base sensor throws when the pressed draggable is no longer registered', () => {
|
||||
const source = createUnregisteredDraggable();
|
||||
const sensor = new TestablePointerSensor(manager);
|
||||
|
||||
expect(() =>
|
||||
sensor.startFromActivation(source, createActivationEvent()),
|
||||
).toThrow('Cannot start a drag operation without a drag source');
|
||||
});
|
||||
|
||||
it('should cancel the gesture when the pressed draggable is no longer registered', () => {
|
||||
const source = createUnregisteredDraggable();
|
||||
const sensor = new TestablePointerSensorWithSourceGuard(manager);
|
||||
|
||||
expect(() =>
|
||||
sensor.startFromActivation(source, createActivationEvent()),
|
||||
).not.toThrow();
|
||||
|
||||
expect(sensor.wasCanceled).toBe(true);
|
||||
expect(manager.dragOperation.status.idle).toBe(true);
|
||||
});
|
||||
|
||||
it('should start the drag when the pressed draggable is still registered', () => {
|
||||
const source = new Draggable(
|
||||
{ id: 'pressed-draggable', element: document.createElement('div') },
|
||||
manager,
|
||||
);
|
||||
source.register();
|
||||
|
||||
const sensor = new TestablePointerSensorWithSourceGuard(manager);
|
||||
|
||||
// An already-aborted controller makes the base handleStart return before
|
||||
// pointer capture, which jsdom does not implement.
|
||||
const abortedController = new AbortController();
|
||||
abortedController.abort();
|
||||
const startSpy = jest
|
||||
.spyOn(manager.actions, 'start')
|
||||
.mockReturnValue(abortedController);
|
||||
|
||||
sensor.startFromActivation(source, createActivationEvent());
|
||||
|
||||
expect(sensor.wasCanceled).toBe(false);
|
||||
expect(startSpy).toHaveBeenCalledWith(expect.objectContaining({ source }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user