fix: guard board drag initiation during data re-render to prevent stale draggable lookup

https://sonarly.com/issue/19024?type=bug

Users on the Kanban board view encounter an unhandled "Invariant failed" crash when attempting to drag a card while the board is re-rendering after a record deletion or data refresh.

Fix: Added `disableDragDuringBoardUpdate()` calls in `RecordBoardDataChangedEffect` to temporarily set `isRecordBoardDropProcessing` to `true` before any board data mutation (delete-one, delete-many, updates triggering refetch, creates triggering refetch, and the default case). This disables the `isDragDisabled` prop on all `<Draggable>` board cards for 500ms, preventing `@hello-pangea/dnd` from attempting to lift a draggable that may be unmounting/remounting during the React reconciliation triggered by the data change.

This uses the exact same mechanism already established in PR #19005 (`8ef99671e2`):
- Same `isRecordBoardDropProcessingComponentState` Jotai atom
- Same `useDebouncedCallback` from `use-debounce` with 500ms timeout
- Same pattern of `store.set(state, true)` then debounced `store.set(state, false)`

The key gap in the original fix was that `isRecordBoardDropProcessing` was only set during active drag-and-drop operations, not during board re-renders triggered by CRUD operations. When a user deleted a record and then tried to drag a card, the board would re-render (unmounting/remounting Draggable components) without the drag guard, causing the `getById` invariant to fail when the DnD library tried to look up a deregistered draggable.
This commit is contained in:
Sonarly Claude Code
2026-03-27 14:35:38 +00:00
parent c96c034908
commit 728e0892d9
@@ -5,6 +5,7 @@ import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/ty
import { useGetShouldInitializeRecordBoardForUpdateInputs } from '@/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs';
import { useRemoveRecordsFromBoard } from '@/object-record/record-board/hooks/useRemoveRecordsFromBoard';
import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery';
import { isRecordBoardDropProcessingComponentState } from '@/object-record/record-board/states/isRecordBoardDropProcessingComponentState';
import { recordGroupFromGroupValueComponentFamilySelector } from '@/object-record/record-group/states/selectors/recordGroupFromGroupValueComponentFamilySelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
@@ -15,6 +16,7 @@ import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/j
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
export const RecordBoardDataChangedEffect = () => {
const store = useStore();
@@ -38,6 +40,23 @@ export const RecordBoardDataChangedEffect = () => {
const { removeRecordsFromBoard } = useRemoveRecordsFromBoard();
const isRecordBoardDropProcessingCallbackState =
useAtomComponentStateCallbackState(
isRecordBoardDropProcessingComponentState,
);
// Temporarily disable drag during board data changes to prevent
// @hello-pangea/dnd invariant errors when draggable components
// unmount/remount during re-renders triggered by data mutations
const debouncedReEnableDrag = useDebouncedCallback(() => {
store.set(isRecordBoardDropProcessingCallbackState, false);
}, 500);
const disableDragDuringBoardUpdate = useCallback(() => {
store.set(isRecordBoardDropProcessingCallbackState, true);
debouncedReEnableDrag();
}, [store, isRecordBoardDropProcessingCallbackState, debouncedReEnableDrag]);
const handleObjectRecordOperation = useCallback(
(
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
@@ -57,12 +76,14 @@ export const RecordBoardDataChangedEffect = () => {
getShouldInitializeRecordBoardForUpdateInputs(updateInputs);
if (shouldInitializeForUpdateOperation) {
disableDragDuringBoardUpdate();
triggerRecordBoardInitialQuery();
}
}
break;
case 'create-one': {
if (objectRecordOperation.createdRecord.position === 'first') {
disableDragDuringBoardUpdate();
triggerRecordBoardInitialQuery();
} else {
const createdRecordPosition =
@@ -106,6 +127,7 @@ export const RecordBoardDataChangedEffect = () => {
const groupIsEmpty = recordIdsWithoutCreatedRecord.length === 0;
if (groupIsEmpty) {
disableDragDuringBoardUpdate();
triggerRecordBoardInitialQuery();
return;
}
@@ -122,6 +144,7 @@ export const RecordBoardDataChangedEffect = () => {
if (
createdRecordPosition < (firstExistingRecordInGroup.position ?? 0)
) {
disableDragDuringBoardUpdate();
triggerRecordBoardInitialQuery();
}
}
@@ -130,6 +153,7 @@ export const RecordBoardDataChangedEffect = () => {
case 'delete-one': {
const removedRecordId = objectRecordOperation.deletedRecordId;
disableDragDuringBoardUpdate();
removeRecordsFromBoard({
recordIdsToRemove: [removedRecordId],
});
@@ -138,6 +162,7 @@ export const RecordBoardDataChangedEffect = () => {
case 'delete-many': {
const removedRecordIds = objectRecordOperation.deletedRecordIds;
disableDragDuringBoardUpdate();
removeRecordsFromBoard({
recordIdsToRemove: removedRecordIds,
});
@@ -148,6 +173,7 @@ export const RecordBoardDataChangedEffect = () => {
return;
}
default: {
disableDragDuringBoardUpdate();
triggerRecordBoardInitialQuery();
}
}
@@ -160,6 +186,7 @@ export const RecordBoardDataChangedEffect = () => {
recordGroupFromGroupValueCallbackState,
recordIndexRecordIdsByGroupCallbackState,
removeRecordsFromBoard,
disableDragDuringBoardUpdate,
],
);