Fix a hotkey scope race condition in command menu (#13025)

Fixes https://github.com/twentyhq/twenty/issues/12885

This PR fixes a hotkey scope race condition happening on note/task
creation.

The problem is that `ActivityRichTextEditor` catches the click event
before the title cell.

So here we prevent this from happening by checking if the record title
cell is.

This is only temporary and should be improved after the persist logic
refactor : https://github.com/twentyhq/core-team-issues/issues/192
This commit is contained in:
Lucas Bordeau
2025-07-03 14:04:37 +02:00
committed by GitHub
parent 74c8ab422b
commit 1f1318febf
5 changed files with 147 additions and 53 deletions

View File

@ -11,7 +11,6 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { isFieldValueReadOnly } from '@/object-record/record-field/utils/isFieldValueReadOnly';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
import { Key } from 'ts-key-enum';
@ -33,7 +32,14 @@ import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly';
import { isInlineCellInEditModeScopedState } from '@/object-record/record-inline-cell/states/isInlineCellInEditModeScopedState';
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { getRecordTitleCellId } from '@/object-record/record-title-cell/utils/getRecordTitleCellId';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import type { PartialBlock } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
@ -86,6 +92,10 @@ export const ActivityRichTextEditor = ({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const { fetchAllRecords: findSoftDeletedAttachments } =
useLazyFetchAllRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
@ -96,11 +106,6 @@ export const ActivityRichTextEditor = ({
},
});
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const { upsertActivity } = useUpsertActivity({
activityObjectNameSingular: activityObjectNameSingular,
});
@ -354,16 +359,60 @@ export const ActivityRichTextEditor = ({
preventDefault: false,
},
);
const handleBlockEditorFocus = () => {
setHotkeyScopeAndMemorizePreviousScope({
scope: ActivityEditorHotkeyScope.ActivityBody,
const { labelIdentifierFieldMetadataItem } = useRecordShowContainerData({
objectNameSingular: activityObjectNameSingular,
objectRecordId: activityId,
});
};
const handlerBlockEditorBlur = () => {
goBackToPreviousHotkeyScope();
};
const recordTitleCellId = getRecordTitleCellId(
activityId,
labelIdentifierFieldMetadataItem?.id,
RecordTitleCellContainerType.ShowPage,
);
const handleBlockEditorFocus = useRecoilCallback(
({ snapshot }) =>
() => {
const isRecordTitleCellOpen = snapshot
.getLoadable(isInlineCellInEditModeScopedState(recordTitleCellId))
.getValue();
if (isRecordTitleCellOpen) {
editor.domElement?.blur();
return;
}
pushFocusItemToFocusStack({
component: {
instanceId: activityId,
type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR,
},
focusId: activityId,
hotkeyScope: {
scope: ActivityEditorHotkeyScope.ActivityBody,
},
});
},
[recordTitleCellId, activityId, editor, pushFocusItemToFocusStack],
);
const handlerBlockEditorBlur = useRecoilCallback(
({ snapshot }) =>
() => {
const isRecordTitleCellOpen = snapshot
.getLoadable(isInlineCellInEditModeScopedState(recordTitleCellId))
.getValue();
if (isRecordTitleCellOpen) {
return;
}
removeFocusItemFromFocusStackById({
focusId: activityId,
});
},
[activityId, recordTitleCellId, removeFocusItemFromFocusStackById],
);
return (
<>

View File

@ -8,8 +8,6 @@ import {
FieldInputEvent,
} from '@/object-record/record-field/types/FieldInputEvent';
import { useInlineCell } from '../../record-inline-cell/hooks/useInlineCell';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { RecordTitleCellContainer } from '@/object-record/record-title-cell/components/RecordTitleCellContainer';
import {
@ -18,6 +16,7 @@ import {
} from '@/object-record/record-title-cell/components/RecordTitleCellContext';
import { RecordTitleCellFieldDisplay } from '@/object-record/record-title-cell/components/RecordTitleCellFieldDisplay';
import { RecordTitleCellFieldInput } from '@/object-record/record-title-cell/components/RecordTitleCellFieldInput';
import { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { getRecordTitleCellId } from '@/object-record/record-title-cell/utils/getRecordTitleCellId';
@ -36,35 +35,50 @@ export const RecordTitleCell = ({
const isFieldInputOnly = useIsFieldInputOnly();
const { closeInlineCell } = useInlineCell(
getRecordTitleCellId(
recordId,
fieldDefinition?.fieldMetadataId,
containerType,
),
);
const { closeRecordTitleCell } = useRecordTitleCell();
const handleEnter: FieldInputEvent = (persistField) => {
closeInlineCell();
closeRecordTitleCell({
recordId,
fieldMetadataId: fieldDefinition?.fieldMetadataId,
containerType,
});
persistField();
};
const handleEscape = () => {
closeInlineCell();
const handleEscape: FieldInputEvent = (persistField) => {
closeRecordTitleCell({
recordId,
fieldMetadataId: fieldDefinition?.fieldMetadataId,
containerType,
});
persistField();
};
const handleTab: FieldInputEvent = (persistField) => {
closeInlineCell();
closeRecordTitleCell({
recordId,
fieldMetadataId: fieldDefinition?.fieldMetadataId,
containerType,
});
persistField();
};
const handleShiftTab: FieldInputEvent = (persistField) => {
closeInlineCell();
closeRecordTitleCell({
recordId,
fieldMetadataId: fieldDefinition?.fieldMetadataId,
containerType,
});
persistField();
};
const handleClickOutside: FieldInputClickOutsideEvent = (persistField) => {
closeInlineCell();
closeRecordTitleCell({
recordId,
fieldMetadataId: fieldDefinition?.fieldMetadataId,
containerType,
});
persistField();
};

View File

@ -1,9 +1,7 @@
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY } from '@/object-record/record-inline-cell/constants/InlineCellHotkeyScopeMemoizeKey';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { TitleInputHotkeyScope } from '@/ui/input/types/TitleInputHotkeyScope';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { Theme, withTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useContext } from 'react';
@ -40,18 +38,16 @@ export const RecordTitleCellSingleTextDisplayMode = () => {
const isEmpty =
recordValue?.[fieldDefinition.metadata.fieldName]?.trim() === '';
const { openInlineCell } = useInlineCell();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const { openRecordTitleCell } = useRecordTitleCell();
return (
<StyledDiv
onClick={() => {
setHotkeyScopeAndMemorizePreviousScope({
scope: TitleInputHotkeyScope.TitleInput,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
openRecordTitleCell({
recordId,
fieldMetadataId: fieldDefinition.fieldMetadataId,
containerType: RecordTitleCellContainerType.ShowPage,
});
openInlineCell();
}}
>
{isEmpty ? (

View File

@ -6,7 +6,9 @@ import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/
import { getRecordTitleCellId } from '@/object-record/record-title-cell/utils/getRecordTitleCellId';
import { TitleInputHotkeyScope } from '@/ui/input/types/TitleInputHotkeyScope';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@ -15,10 +17,9 @@ export const useRecordTitleCell = () => {
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const closeRecordTitleCell = useRecoilCallback(
({ set }) =>
@ -38,11 +39,17 @@ export const useRecordTitleCell = () => {
false,
);
goBackToPreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY);
removeFocusItemFromFocusStackById({
focusId: getRecordTitleCellId(
recordId,
fieldMetadataId,
containerType,
),
});
goBackToPreviousDropdownFocusId();
},
[goBackToPreviousDropdownFocusId, goBackToPreviousHotkeyScope],
[goBackToPreviousDropdownFocusId, removeFocusItemFromFocusStackById],
);
const initFieldInputDraftValue = useInitDraftValueV2();
@ -61,14 +68,41 @@ export const useRecordTitleCell = () => {
customEditHotkeyScopeForField?: HotkeyScope;
}) => {
if (isDefined(customEditHotkeyScopeForField)) {
setHotkeyScopeAndMemorizePreviousScope({
scope: customEditHotkeyScopeForField.scope,
customScopes: customEditHotkeyScopeForField.customScopes,
pushFocusItemToFocusStack({
focusId: getRecordTitleCellId(
recordId,
fieldMetadataId,
containerType,
),
component: {
type: FocusComponentType.OPENED_FIELD_INPUT,
instanceId: getRecordTitleCellId(
recordId,
fieldMetadataId,
containerType,
),
},
hotkeyScope: customEditHotkeyScopeForField,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
} else {
setHotkeyScopeAndMemorizePreviousScope({
pushFocusItemToFocusStack({
focusId: getRecordTitleCellId(
recordId,
fieldMetadataId,
containerType,
),
component: {
type: FocusComponentType.OPENED_FIELD_INPUT,
instanceId: getRecordTitleCellId(
recordId,
fieldMetadataId,
containerType,
),
},
hotkeyScope: {
scope: TitleInputHotkeyScope.TitleInput,
},
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
}
@ -98,7 +132,7 @@ export const useRecordTitleCell = () => {
fieldComponentInstanceId: recordTitleCellId,
});
},
[initFieldInputDraftValue, setHotkeyScopeAndMemorizePreviousScope],
[initFieldInputDraftValue, pushFocusItemToFocusStack],
);
return {

View File

@ -9,4 +9,5 @@ export enum FocusComponentType {
RECORD_TABLE_CELL = 'record-table-cell',
RECORD_BOARD = 'record-board',
RECORD_BOARD_CARD = 'record-board-card',
ACTIVITY_RICH_TEXT_EDITOR = 'activity-rich-text-editor',
}