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 { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { isFieldValueReadOnly } from '@/object-record/record-field/utils/isFieldValueReadOnly'; import { isFieldValueReadOnly } from '@/object-record/record-field/utils/isFieldValueReadOnly';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; 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 { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey'; import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
import { Key } from 'ts-key-enum'; 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 { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly'; 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 { 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 type { PartialBlock } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css'; import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css'; import '@blocknote/mantine/style.css';
@ -86,6 +92,10 @@ export const ActivityRichTextEditor = ({
objectNameSingular: CoreObjectNameSingular.Attachment, objectNameSingular: CoreObjectNameSingular.Attachment,
}); });
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const { fetchAllRecords: findSoftDeletedAttachments } = const { fetchAllRecords: findSoftDeletedAttachments } =
useLazyFetchAllRecords({ useLazyFetchAllRecords({
objectNameSingular: CoreObjectNameSingular.Attachment, objectNameSingular: CoreObjectNameSingular.Attachment,
@ -96,11 +106,6 @@ export const ActivityRichTextEditor = ({
}, },
}); });
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const { upsertActivity } = useUpsertActivity({ const { upsertActivity } = useUpsertActivity({
activityObjectNameSingular: activityObjectNameSingular, activityObjectNameSingular: activityObjectNameSingular,
}); });
@ -354,16 +359,60 @@ export const ActivityRichTextEditor = ({
preventDefault: false, preventDefault: false,
}, },
); );
const { labelIdentifierFieldMetadataItem } = useRecordShowContainerData({
objectNameSingular: activityObjectNameSingular,
objectRecordId: activityId,
});
const handleBlockEditorFocus = () => { const recordTitleCellId = getRecordTitleCellId(
setHotkeyScopeAndMemorizePreviousScope({ activityId,
scope: ActivityEditorHotkeyScope.ActivityBody, labelIdentifierFieldMetadataItem?.id,
}); RecordTitleCellContainerType.ShowPage,
}; );
const handlerBlockEditorBlur = () => { const handleBlockEditorFocus = useRecoilCallback(
goBackToPreviousHotkeyScope(); ({ 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 ( return (
<> <>

View File

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

View File

@ -1,9 +1,7 @@
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; 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 { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { TitleInputHotkeyScope } from '@/ui/input/types/TitleInputHotkeyScope'; import { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { Theme, withTheme } from '@emotion/react'; import { Theme, withTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useContext } from 'react'; import { useContext } from 'react';
@ -40,18 +38,16 @@ export const RecordTitleCellSingleTextDisplayMode = () => {
const isEmpty = const isEmpty =
recordValue?.[fieldDefinition.metadata.fieldName]?.trim() === ''; recordValue?.[fieldDefinition.metadata.fieldName]?.trim() === '';
const { openInlineCell } = useInlineCell(); const { openRecordTitleCell } = useRecordTitleCell();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
return ( return (
<StyledDiv <StyledDiv
onClick={() => { onClick={() => {
setHotkeyScopeAndMemorizePreviousScope({ openRecordTitleCell({
scope: TitleInputHotkeyScope.TitleInput, recordId,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, fieldMetadataId: fieldDefinition.fieldMetadataId,
containerType: RecordTitleCellContainerType.ShowPage,
}); });
openInlineCell();
}} }}
> >
{isEmpty ? ( {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 { getRecordTitleCellId } from '@/object-record/record-title-cell/utils/getRecordTitleCellId';
import { TitleInputHotkeyScope } from '@/ui/input/types/TitleInputHotkeyScope'; import { TitleInputHotkeyScope } from '@/ui/input/types/TitleInputHotkeyScope';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId'; 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 { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
@ -15,10 +17,9 @@ export const useRecordTitleCell = () => {
const { goBackToPreviousDropdownFocusId } = const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId(); useGoBackToPreviousDropdownFocusId();
const { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
setHotkeyScopeAndMemorizePreviousScope, const { removeFocusItemFromFocusStackById } =
goBackToPreviousHotkeyScope, useRemoveFocusItemFromFocusStackById();
} = usePreviousHotkeyScope();
const closeRecordTitleCell = useRecoilCallback( const closeRecordTitleCell = useRecoilCallback(
({ set }) => ({ set }) =>
@ -38,11 +39,17 @@ export const useRecordTitleCell = () => {
false, false,
); );
goBackToPreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY); removeFocusItemFromFocusStackById({
focusId: getRecordTitleCellId(
recordId,
fieldMetadataId,
containerType,
),
});
goBackToPreviousDropdownFocusId(); goBackToPreviousDropdownFocusId();
}, },
[goBackToPreviousDropdownFocusId, goBackToPreviousHotkeyScope], [goBackToPreviousDropdownFocusId, removeFocusItemFromFocusStackById],
); );
const initFieldInputDraftValue = useInitDraftValueV2(); const initFieldInputDraftValue = useInitDraftValueV2();
@ -61,14 +68,41 @@ export const useRecordTitleCell = () => {
customEditHotkeyScopeForField?: HotkeyScope; customEditHotkeyScopeForField?: HotkeyScope;
}) => { }) => {
if (isDefined(customEditHotkeyScopeForField)) { if (isDefined(customEditHotkeyScopeForField)) {
setHotkeyScopeAndMemorizePreviousScope({ pushFocusItemToFocusStack({
scope: customEditHotkeyScopeForField.scope, focusId: getRecordTitleCellId(
customScopes: customEditHotkeyScopeForField.customScopes, recordId,
fieldMetadataId,
containerType,
),
component: {
type: FocusComponentType.OPENED_FIELD_INPUT,
instanceId: getRecordTitleCellId(
recordId,
fieldMetadataId,
containerType,
),
},
hotkeyScope: customEditHotkeyScopeForField,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
}); });
} else { } else {
setHotkeyScopeAndMemorizePreviousScope({ pushFocusItemToFocusStack({
scope: TitleInputHotkeyScope.TitleInput, 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, memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
}); });
} }
@ -98,7 +132,7 @@ export const useRecordTitleCell = () => {
fieldComponentInstanceId: recordTitleCellId, fieldComponentInstanceId: recordTitleCellId,
}); });
}, },
[initFieldInputDraftValue, setHotkeyScopeAndMemorizePreviousScope], [initFieldInputDraftValue, pushFocusItemToFocusStack],
); );
return { return {

View File

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