Compare commits

...
Author SHA1 Message Date
sonarly-bot 28f836200b 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).
2026-05-19 10:45:46 +00:00
2 changed files with 191 additions and 1 deletions
@@ -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<HTMLDivElement>) => {
if (!isEditableElement(event.target)) {
return;
}
pushFrontComponentInputFocusItem();
},
[pushFrontComponentInputFocusItem],
);
const handleBlurCapture = useCallback(
(event: FocusEvent<HTMLDivElement>) => {
if (!isEditableElement(event.target)) {
return;
}
if (
event.currentTarget.contains(event.relatedTarget) &&
isEditableElement(event.relatedTarget)
) {
return;
}
removeFrontComponentInputFocusItem();
},
[removeFrontComponentInputFocusItem],
);
useEffect(() => {
return () => {
removeFrontComponentInputFocusItem();
};
}, [removeFrontComponentInputFocusItem]);
return (
<FrontComponentInstanceContext.Provider
value={{ instanceId: frontComponentId }}
>
{children}
<div
style={{ display: 'contents' }}
onFocusCapture={handleFocusCapture}
onBlurCapture={handleBlurCapture}
>
{children}
</div>
</FrontComponentInstanceContext.Provider>
);
};
@@ -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(
<FrontComponentRendererProvider frontComponentId="front-component-id">
<input />
</FrontComponentRendererProvider>,
);
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(
<FrontComponentRendererProvider frontComponentId="front-component-id">
<>
<input data-testid="first-input" />
<textarea data-testid="second-input" />
</>
</FrontComponentRendererProvider>,
);
const firstInput = getByTestId('first-input');
const secondInput = getByTestId('second-input');
fireEvent.focus(firstInput);
fireEvent.blur(firstInput, { relatedTarget: secondInput });
expect(mockRemoveFocusItemFromFocusStackById).not.toHaveBeenCalledWith({
focusId: 'front-component-renderer-front-component-id-input',
});
});
it('should restore global hotkeys when editable input loses focus', () => {
const { getByRole } = render(
<FrontComponentRendererProvider frontComponentId="front-component-id">
<>
<input />
<button type="button">Action</button>
</>
</FrontComponentRendererProvider>,
);
const input = getByRole('textbox');
const button = getByRole('button', { name: 'Action' });
fireEvent.focus(input);
fireEvent.blur(input, { relatedTarget: button });
expect(mockRemoveFocusItemFromFocusStackById).toHaveBeenCalledWith({
focusId: 'front-component-renderer-front-component-id-input',
});
});
});