From 28f836200b262b025df7f15fdf496fe6b1b93833 Mon Sep 17 00:00:00 2001 From: sonarly-bot Date: Tue, 19 May 2026 10:45:46 +0000 Subject: [PATCH] fix(front-component-renderer): gate global hotkeys on input focus https://sonarly.com/issue/38618?type=bug Typing inside worker-rendered front-component inputs can trigger host global hotkeys (for example Fix: Implemented a host-side focus bridge for worker-rendered front-component inputs so global hotkeys are suppressed while users type. What changed: 1. `FrontComponentRendererProvider` now captures focus/blur events from descendants and detects editable targets (`input` text-like types, `textarea`, `contentEditable`). 2. On editable focus, it pushes a focus-stack item with: - a stable provider-scoped focus id - `enableGlobalHotkeysConflictingWithKeyboard: false` 3. On editable blur (unless focus is moving to another editable element inside the same provider), it removes that focus-stack item. 4. Added unmount cleanup to ensure no stale focus-stack item remains if renderer unmounts while focused. 5. Added tests covering: - push on editable focus - no remove when moving between editable fields - remove when leaving editable context This directly addresses the root cause: front-component inputs were not participating in the host focus-stack hotkey gate, so global shortcuts remained active during typing. Authored by Sonarly by autonomous analysis (run 43975). --- .../FrontComponentRendererProvider.tsx | 105 +++++++++++++++++- .../FrontComponentRendererProvider.test.tsx | 87 +++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-front/src/modules/front-components/components/__tests__/FrontComponentRendererProvider.test.tsx diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx index f6a92bc7def..99ac398825c 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx @@ -1,4 +1,40 @@ import { FrontComponentInstanceContext } from '@/front-components/states/contexts/FrontComponentInstanceContext'; +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 FocusEvent, useCallback, useEffect, useMemo } from 'react'; + +const INPUT_TYPES_WITHOUT_TEXT_ENTRY = new Set([ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit', +]); + +const isEditableElement = (element: EventTarget | null): boolean => { + if (!(element instanceof HTMLElement)) { + return false; + } + + if (element instanceof HTMLTextAreaElement) { + return !element.disabled; + } + + if (element instanceof HTMLInputElement) { + return ( + !element.disabled && + !INPUT_TYPES_WITHOUT_TEXT_ENTRY.has(element.type.toLowerCase()) + ); + } + + return element.isContentEditable; +}; type FrontComponentRendererProviderProps = { frontComponentId: string; @@ -9,11 +45,78 @@ export const FrontComponentRendererProvider = ({ frontComponentId, children, }: FrontComponentRendererProviderProps) => { + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + const focusId = useMemo( + () => `front-component-renderer-${frontComponentId}-input`, + [frontComponentId], + ); + + const pushFrontComponentInputFocusItem = useCallback(() => { + pushFocusItemToFocusStack({ + focusId, + component: { + type: FocusComponentType.TEXT_INPUT, + instanceId: focusId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + }, [focusId, pushFocusItemToFocusStack]); + + const removeFrontComponentInputFocusItem = useCallback(() => { + removeFocusItemFromFocusStackById({ focusId }); + }, [focusId, removeFocusItemFromFocusStackById]); + + const handleFocusCapture = useCallback( + (event: FocusEvent) => { + if (!isEditableElement(event.target)) { + return; + } + + pushFrontComponentInputFocusItem(); + }, + [pushFrontComponentInputFocusItem], + ); + + const handleBlurCapture = useCallback( + (event: FocusEvent) => { + if (!isEditableElement(event.target)) { + return; + } + + if ( + event.currentTarget.contains(event.relatedTarget) && + isEditableElement(event.relatedTarget) + ) { + return; + } + + removeFrontComponentInputFocusItem(); + }, + [removeFrontComponentInputFocusItem], + ); + + useEffect(() => { + return () => { + removeFrontComponentInputFocusItem(); + }; + }, [removeFrontComponentInputFocusItem]); + return ( - {children} +
+ {children} +
); }; diff --git a/packages/twenty-front/src/modules/front-components/components/__tests__/FrontComponentRendererProvider.test.tsx b/packages/twenty-front/src/modules/front-components/components/__tests__/FrontComponentRendererProvider.test.tsx new file mode 100644 index 00000000000..aac199a972e --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/components/__tests__/FrontComponentRendererProvider.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render } from '@testing-library/react'; + +import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider'; + +const mockPushFocusItemToFocusStack = jest.fn(); +const mockRemoveFocusItemFromFocusStackById = jest.fn(); + +jest.mock('@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack', () => ({ + usePushFocusItemToFocusStack: () => ({ + pushFocusItemToFocusStack: mockPushFocusItemToFocusStack, + }), +})); + +jest.mock('@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById', () => ({ + useRemoveFocusItemFromFocusStackById: () => ({ + removeFocusItemFromFocusStackById: mockRemoveFocusItemFromFocusStackById, + }), +})); + +describe('FrontComponentRendererProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should disable conflicting global hotkeys when focusing an editable input', () => { + const { getByRole } = render( + + + , + ); + + fireEvent.focus(getByRole('textbox')); + + expect(mockPushFocusItemToFocusStack).toHaveBeenCalledWith({ + focusId: 'front-component-renderer-front-component-id-input', + component: { + type: 'text-input', + instanceId: 'front-component-renderer-front-component-id-input', + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + }); + + it('should keep focus stack item while moving focus between editable fields', () => { + const { getByTestId } = render( + + <> + +