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:
Félix Malfait
2026-08-05 16:29:45 +02:00
committed by GitHub
parent 3f9137bd4e
commit 198ffbb6bd
3 changed files with 118 additions and 2 deletions
@@ -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,
}),
@@ -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);
}
}
@@ -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 }));
});
});