From 923035bf48ba77e9c5038d6005b5217659661ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:09:40 +0200 Subject: [PATCH] Extract front component host wrapper into hooks (#23262) Refactors `createHtmlHostWrapper` into composable hooks (`useHtmlHostElementProps`, `useComposedElementRef`, `useCaretPreservingElementRef`) as groundwork for the geometry mirror. Behavior-focused, no feature change: - Caret preservation moves to a stable ref + `useLayoutEffect` re-assertion (covered by the caret suites). Highest regression surface in the series, isolated here for focused review. - The remote `ref` prop is now swallowed via `INTERNAL_PROPS` instead of leaking onto host elements. First of three PRs splitting the geometry mirror work. Review in cubic --- .../__tests__/useComposedElementRef.test.tsx | 85 ++++++++++++++++++ .../hooks/useCaretPreservingElementRef.ts | 38 ++++++++ .../src/host/hooks/useComposedElementRef.ts | 18 ++++ .../src/host/hooks/useHtmlHostElementProps.ts | 59 +++++++++++++ .../useReactUnsupportedEventListenerRef.ts | 10 +-- .../src/host/types/ElementRefCallback.ts | 1 + .../createCaretPreservingElement.test.ts | 16 ++-- .../__tests__/createHtmlHostWrapper.test.ts | 40 +++++++++ .../createHtmlHostWrapperCaretScope.test.ts | 70 +++++++++++++++ .../buildHostReactPropsFromRemoteProps.ts | 2 +- .../utils/createCaretPreservingElement.ts | 19 ++-- .../src/host/utils/createHtmlHostWrapper.ts | 88 ++++++++++--------- .../src/host/utils/createPlainHostElement.ts | 30 +++++++ 13 files changed, 406 insertions(+), 70 deletions(-) create mode 100644 packages/twenty-front-component-renderer/src/host/hooks/__tests__/useComposedElementRef.test.tsx create mode 100644 packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts create mode 100644 packages/twenty-front-component-renderer/src/host/hooks/useComposedElementRef.ts create mode 100644 packages/twenty-front-component-renderer/src/host/hooks/useHtmlHostElementProps.ts create mode 100644 packages/twenty-front-component-renderer/src/host/types/ElementRefCallback.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapperCaretScope.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/createPlainHostElement.ts diff --git a/packages/twenty-front-component-renderer/src/host/hooks/__tests__/useComposedElementRef.test.tsx b/packages/twenty-front-component-renderer/src/host/hooks/__tests__/useComposedElementRef.test.tsx new file mode 100644 index 0000000000..5d22e690cc --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/hooks/__tests__/useComposedElementRef.test.tsx @@ -0,0 +1,85 @@ +import '../../utils/__tests__/setupServerRenderingGlobals'; + +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; +import { useComposedElementRef } from '../useComposedElementRef'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const seenComposedElementRefs: ElementRefCallback[] = []; + +const TestComponent = ({ + elementRefs, +}: { + elementRefs: (ElementRefCallback | undefined)[]; +}) => { + const composedElementRef = useComposedElementRef(elementRefs); + seenComposedElementRefs.push(composedElementRef); + + return createElement('div', { ref: composedElementRef }); +}; + +describe('useComposedElementRef', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + seenComposedElementRefs.length = 0; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + const renderWith = (elementRefs: (ElementRefCallback | undefined)[]) => { + act(() => { + root.render(createElement(TestComponent, { elementRefs })); + }); + }; + + it('should call every defined ref with the element', () => { + const firstRef = jest.fn(); + const secondRef = jest.fn(); + + renderWith([firstRef, undefined, secondRef]); + + expect(firstRef).toHaveBeenCalledTimes(1); + expect(secondRef).toHaveBeenCalledTimes(1); + expect(firstRef.mock.calls[0][0]).toBe(container.firstElementChild); + }); + + it('should keep the same ref identity across re-renders', () => { + const elementRef = jest.fn(); + + renderWith([elementRef]); + renderWith([elementRef]); + renderWith([elementRef]); + + expect(seenComposedElementRefs).toHaveLength(3); + expect(seenComposedElementRefs[0]).toBe(seenComposedElementRefs[1]); + expect(seenComposedElementRefs[0]).toBe(seenComposedElementRefs[2]); + expect(elementRef).toHaveBeenCalledTimes(1); + }); + + it('should call every ref with null on unmount', () => { + const elementRef = jest.fn(); + + renderWith([elementRef]); + + act(() => { + root.render(null); + }); + + expect(elementRef).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts b/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts new file mode 100644 index 0000000000..49c4996f36 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts @@ -0,0 +1,38 @@ +import { isString } from '@sniptt/guards'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; +import { syncValuePreservingCaret } from '@/host/utils/syncValuePreservingCaret'; + +export const useCaretPreservingElementRef = ( + composedElementRef: ElementRefCallback, + value: unknown, +): ElementRefCallback => { + const latestComposedElementRefRef = useRef(composedElementRef); + latestComposedElementRefRef.current = composedElementRef; + + const attachedElementRef = useRef(null); + + const [caretPreservingElementRef] = useState( + () => (element: Element | null) => { + attachedElementRef.current = element; + latestComposedElementRefRef.current(element); + }, + ); + + useLayoutEffect(() => { + const attachedElement = attachedElementRef.current; + + if (!isDefined(attachedElement) || !isString(value)) { + return; + } + + syncValuePreservingCaret( + attachedElement as HTMLInputElement | HTMLTextAreaElement, + value, + ); + }); + + return caretPreservingElementRef; +}; diff --git a/packages/twenty-front-component-renderer/src/host/hooks/useComposedElementRef.ts b/packages/twenty-front-component-renderer/src/host/hooks/useComposedElementRef.ts new file mode 100644 index 0000000000..c288148f1a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/hooks/useComposedElementRef.ts @@ -0,0 +1,18 @@ +import { useRef, useState } from 'react'; + +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; + +export const useComposedElementRef = ( + elementRefs: (ElementRefCallback | undefined)[], +): ElementRefCallback => { + const latestElementRefsRef = useRef(elementRefs); + latestElementRefsRef.current = elementRefs; + + const [composedElementRef] = useState(() => (element: Element | null) => { + for (const elementRef of latestElementRefsRef.current) { + elementRef?.(element); + } + }); + + return composedElementRef; +}; diff --git a/packages/twenty-front-component-renderer/src/host/hooks/useHtmlHostElementProps.ts b/packages/twenty-front-component-renderer/src/host/hooks/useHtmlHostElementProps.ts new file mode 100644 index 0000000000..c639ad28a0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/hooks/useHtmlHostElementProps.ts @@ -0,0 +1,59 @@ +import { useContext } from 'react'; + +import { + FrontComponentInputFocusContext, + type SetEditableFocused, +} from '@/host/contexts/FrontComponentInputFocusContext'; +import { useComposedElementRef } from '@/host/hooks/useComposedElementRef'; +import { useReactUnsupportedEventListenerRef } from '@/host/hooks/useReactUnsupportedEventListenerRef'; +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; +import { buildHostReactPropsFromRemoteProps } from '@/host/utils/buildHostReactPropsFromRemoteProps'; +import { createDropTargetGuardProps } from '@/host/utils/createDropTargetGuardProps'; +import { extractReactUnsupportedEventHandlers } from '@/host/utils/extractReactUnsupportedEventHandlers'; +import { preventDefaultThenForwardToRemote } from '@/host/utils/preventDefaultThenForwardToRemote'; +import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox'; + +type HtmlHostElementProps = { + setEditableFocused: SetEditableFocused | null; + reactBindableProps: Record; + hostEnforcedProps: Record; + composedElementRef: ElementRefCallback; +}; + +export const useHtmlHostElementProps = ( + props: Record, + htmlTag: string, +): HtmlHostElementProps => { + const setEditableFocused = useContext(FrontComponentInputFocusContext); + + const { reactUnsupportedEventHandlers, reactBindableProps } = + extractReactUnsupportedEventHandlers( + buildHostReactPropsFromRemoteProps(props, htmlTag), + ); + + const reactUnsupportedEventListenerRef = useReactUnsupportedEventListenerRef( + reactUnsupportedEventHandlers, + ); + + const composedElementRef = useComposedElementRef([ + reactUnsupportedEventListenerRef, + ]); + + const hostEnforcedProps: Record = { + ...createDropTargetGuardProps(reactBindableProps), + ...(htmlTag === 'iframe' && { + sandbox: sanitizeIframeSandbox(reactBindableProps.sandbox), + }), + // React 19 blocks the previous `action="javascript:void(0)"` guard. + ...(htmlTag === 'form' && { + onSubmit: preventDefaultThenForwardToRemote(reactBindableProps.onSubmit), + }), + }; + + return { + setEditableFocused, + reactBindableProps, + hostEnforcedProps, + composedElementRef, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/host/hooks/useReactUnsupportedEventListenerRef.ts b/packages/twenty-front-component-renderer/src/host/hooks/useReactUnsupportedEventListenerRef.ts index 3b25de1e9f..6c71c217ca 100644 --- a/packages/twenty-front-component-renderer/src/host/hooks/useReactUnsupportedEventListenerRef.ts +++ b/packages/twenty-front-component-renderer/src/host/hooks/useReactUnsupportedEventListenerRef.ts @@ -1,11 +1,12 @@ import { useRef, useState } from 'react'; +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; import { type ReactUnsupportedEventHandlers } from '@/host/types/ReactUnsupportedEventHandlers'; import { createReactUnsupportedEventListenerRef } from '@/host/utils/createReactUnsupportedEventListenerRef'; export const useReactUnsupportedEventListenerRef = ( reactUnsupportedEventHandlers: ReactUnsupportedEventHandlers, -): ((element: Element | null) => void) | undefined => { +): ElementRefCallback => { const latestHandlersRef = useRef(reactUnsupportedEventHandlers); latestHandlersRef.current = reactUnsupportedEventHandlers; @@ -13,10 +14,5 @@ export const useReactUnsupportedEventListenerRef = ( createReactUnsupportedEventListenerRef(latestHandlersRef), ); - const hasReactUnsupportedEventHandlers = - Object.keys(reactUnsupportedEventHandlers).length > 0; - - return hasReactUnsupportedEventHandlers - ? reactUnsupportedEventListenerRef - : undefined; + return reactUnsupportedEventListenerRef; }; diff --git a/packages/twenty-front-component-renderer/src/host/types/ElementRefCallback.ts b/packages/twenty-front-component-renderer/src/host/types/ElementRefCallback.ts new file mode 100644 index 0000000000..968692314f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/types/ElementRefCallback.ts @@ -0,0 +1 @@ +export type ElementRefCallback = (element: Element | null) => void; diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts index 5c515060e2..02d6d69ae8 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts @@ -10,8 +10,9 @@ describe('createCaretPreservingElement', () => { const element = createCaretPreservingElement({ htmlTag: 'input', reactBindableProps: { type: 'text' }, - hostEnforcedProps: undefined, + hostEnforcedProps: {}, setEditableFocused: null, + caretPreservingElementRef: () => {}, }); expect(element.type).toBe('input'); @@ -22,8 +23,9 @@ describe('createCaretPreservingElement', () => { const element = createCaretPreservingElement({ htmlTag: 'input', reactBindableProps: { value: 'hello' }, - hostEnforcedProps: undefined, + hostEnforcedProps: {}, setEditableFocused: null, + caretPreservingElementRef: () => {}, }); expect(getProps(element).defaultValue).toBe('hello'); @@ -33,8 +35,9 @@ describe('createCaretPreservingElement', () => { const element = createCaretPreservingElement({ htmlTag: 'input', reactBindableProps: { value: 'v', defaultValue: 'd' }, - hostEnforcedProps: undefined, + hostEnforcedProps: {}, setEditableFocused: null, + caretPreservingElementRef: () => {}, }); expect(getProps(element).defaultValue).toBe('d'); @@ -46,6 +49,7 @@ describe('createCaretPreservingElement', () => { reactBindableProps: {}, hostEnforcedProps: { readOnly: true }, setEditableFocused: null, + caretPreservingElementRef: () => {}, }); expect(getProps(element).readOnly).toBe(true); @@ -57,8 +61,9 @@ describe('createCaretPreservingElement', () => { const element = createCaretPreservingElement({ htmlTag: 'input', reactBindableProps: { onFocus }, - hostEnforcedProps: undefined, + hostEnforcedProps: {}, setEditableFocused, + caretPreservingElementRef: () => {}, }); const event = {} as never; @@ -73,8 +78,9 @@ describe('createCaretPreservingElement', () => { const element = createCaretPreservingElement({ htmlTag: 'input', reactBindableProps: {}, - hostEnforcedProps: undefined, + hostEnforcedProps: {}, setEditableFocused, + caretPreservingElementRef: () => {}, }); (getProps(element).onBlur as (event: unknown) => void)({} as never); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts index 7104e5b85c..491975f2c8 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts @@ -135,6 +135,46 @@ describe('createHtmlHostWrapper client events', () => { ); }); + it('should re-assert an unchanged controlled value on an unrelated re-render', () => { + const Wrapper = createHtmlHostWrapper('input'); + + act(() => { + root.render(createElement(Wrapper, { type: 'text', value: 'fixed' })); + }); + + const node = container.firstElementChild as HTMLInputElement; + node.value = 'fixed-typed'; + + act(() => { + root.render( + createElement(Wrapper, { + type: 'text', + value: 'fixed', + className: 'rerendered', + }), + ); + }); + + expect(node.value).toBe('fixed'); + }); + + it('should clear the host input when a controlled value becomes empty', () => { + const Wrapper = createHtmlHostWrapper('input'); + + act(() => { + root.render(createElement(Wrapper, { type: 'text', value: 'abc' })); + }); + + const node = container.firstElementChild as HTMLInputElement; + expect(node.value).toBe('abc'); + + act(() => { + root.render(createElement(Wrapper, { type: 'text', value: '' })); + }); + + expect(node.value).toBe(''); + }); + it('should stop forwarding focusin after the handler prop is removed', () => { const handleFocusIn = jest.fn(); const Wrapper = createHtmlHostWrapper('div'); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapperCaretScope.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapperCaretScope.test.ts new file mode 100644 index 0000000000..1c0a7b7d28 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapperCaretScope.test.ts @@ -0,0 +1,70 @@ +import './setupServerRenderingGlobals'; + +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { useCaretPreservingElementRef } from '@/host/hooks/useCaretPreservingElementRef'; +import { createHtmlHostWrapper } from '../createHtmlHostWrapper'; + +jest.mock('@/host/hooks/useCaretPreservingElementRef', () => ({ + useCaretPreservingElementRef: jest.fn(() => () => {}), +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +describe('createHtmlHostWrapper caret hook scope', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + jest.clearAllMocks(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + const renderWrapper = (htmlTag: string, props: Record) => { + act(() => { + root.render(createElement(createHtmlHostWrapper(htmlTag), props)); + }); + }; + + it('should not call the caret hook for a non editable tag', () => { + renderWrapper('div', {}); + + expect(useCaretPreservingElementRef).not.toHaveBeenCalled(); + }); + + it('should not call the caret hook for a span', () => { + renderWrapper('span', {}); + + expect(useCaretPreservingElementRef).not.toHaveBeenCalled(); + }); + + it('should call the caret hook for a text input', () => { + renderWrapper('input', { type: 'text' }); + + expect(useCaretPreservingElementRef).toHaveBeenCalled(); + }); + + it('should call the caret hook for a textarea', () => { + renderWrapper('textarea', {}); + + expect(useCaretPreservingElementRef).toHaveBeenCalled(); + }); + + it('should keep rendering a plain input element for a checkbox', () => { + renderWrapper('input', { type: 'checkbox' }); + + expect(container.firstElementChild?.tagName).toBe('INPUT'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/buildHostReactPropsFromRemoteProps.ts b/packages/twenty-front-component-renderer/src/host/utils/buildHostReactPropsFromRemoteProps.ts index 03c5657587..c8847ea7ee 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/buildHostReactPropsFromRemoteProps.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/buildHostReactPropsFromRemoteProps.ts @@ -9,7 +9,7 @@ import { parseCssString } from '@/host/utils/parseCssString'; import { wrapEventHandler } from '@/host/utils/wrapEventHandler'; import { type SerializedEventData } from '@/types/SerializedEventData'; -const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']); +const INTERNAL_PROPS = new Set(['element', 'receiver', 'components', 'ref']); // Both spellings are indexed: dblclick arrives as ondblclick or onDoubleClick. const LOWERCASE_EVENT_PROP_TO_REACT_PROP: Record = diff --git a/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts b/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts index fdc7ef79a9..3c227060d5 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts @@ -1,18 +1,17 @@ import { isFunction, isNonEmptyString } from '@sniptt/guards'; import React from 'react'; -import { isDefined } from 'twenty-shared/utils'; import { type SetEditableFocused } from '@/host/contexts/FrontComponentInputFocusContext'; -import { syncValuePreservingCaret } from '@/host/utils/syncValuePreservingCaret'; +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement; type CreateCaretPreservingElementParams = { htmlTag: 'input' | 'textarea'; reactBindableProps: Record; - hostEnforcedProps: Record | undefined; + hostEnforcedProps: Record; setEditableFocused: SetEditableFocused | null; - reactUnsupportedEventListenerRef?: (node: Element | null) => void; + caretPreservingElementRef: ElementRefCallback; }; export const createCaretPreservingElement = ({ @@ -20,7 +19,7 @@ export const createCaretPreservingElement = ({ reactBindableProps, hostEnforcedProps, setEditableFocused, - reactUnsupportedEventListenerRef, + caretPreservingElementRef, }: CreateCaretPreservingElementParams) => { const { value, @@ -55,14 +54,6 @@ export const createCaretPreservingElement = ({ defaultValue: initialValue, onFocus: handleFocus, onBlur: handleBlur, - ref: (node: CaretPreservingElement | null) => { - reactUnsupportedEventListenerRef?.(node); - if (!isDefined(node)) { - return; - } - if (isNonEmptyString(value)) { - syncValuePreservingCaret(node, value); - } - }, + ref: caretPreservingElementRef, }); }; diff --git a/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts b/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts index 281965a1eb..edd5a25f2c 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts @@ -1,14 +1,10 @@ -import React, { useContext } from 'react'; +import React from 'react'; -import { FrontComponentInputFocusContext } from '@/host/contexts/FrontComponentInputFocusContext'; -import { useReactUnsupportedEventListenerRef } from '@/host/hooks/useReactUnsupportedEventListenerRef'; -import { buildHostReactPropsFromRemoteProps } from '@/host/utils/buildHostReactPropsFromRemoteProps'; +import { useCaretPreservingElementRef } from '@/host/hooks/useCaretPreservingElementRef'; +import { useHtmlHostElementProps } from '@/host/hooks/useHtmlHostElementProps'; import { createCaretPreservingElement } from '@/host/utils/createCaretPreservingElement'; -import { createDropTargetGuardProps } from '@/host/utils/createDropTargetGuardProps'; -import { extractReactUnsupportedEventHandlers } from '@/host/utils/extractReactUnsupportedEventHandlers'; +import { createPlainHostElement } from '@/host/utils/createPlainHostElement'; import { isTextLikeInputType } from '@/host/utils/isTextLikeInputType'; -import { preventDefaultThenForwardToRemote } from '@/host/utils/preventDefaultThenForwardToRemote'; -import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox'; const VOID_ELEMENTS = new Set([ 'area', @@ -26,58 +22,64 @@ const VOID_ELEMENTS = new Set([ 'wbr', ]); +const CARET_PRESERVING_TAGS = new Set(['input', 'textarea']); + type WrapperProps = { children?: React.ReactNode } & Record; export const createHtmlHostWrapper = (htmlTag: string) => { const isVoid = VOID_ELEMENTS.has(htmlTag); - const isIframe = htmlTag === 'iframe'; - const isForm = htmlTag === 'form'; + + if (!CARET_PRESERVING_TAGS.has(htmlTag)) { + return ({ children, ...props }: WrapperProps) => { + const { reactBindableProps, hostEnforcedProps, composedElementRef } = + useHtmlHostElementProps(props, htmlTag); + + return createPlainHostElement({ + htmlTag, + isVoid, + reactBindableProps, + hostEnforcedProps, + composedElementRef, + children, + }); + }; + } + + const caretPreservingTag = htmlTag as 'input' | 'textarea'; return ({ children, ...props }: WrapperProps) => { - const setEditableFocused = useContext(FrontComponentInputFocusContext); + const { + setEditableFocused, + reactBindableProps, + hostEnforcedProps, + composedElementRef, + } = useHtmlHostElementProps(props, htmlTag); - const { reactUnsupportedEventHandlers, reactBindableProps } = - extractReactUnsupportedEventHandlers( - buildHostReactPropsFromRemoteProps(props, htmlTag), - ); - - const reactUnsupportedEventListenerRef = - useReactUnsupportedEventListenerRef(reactUnsupportedEventHandlers); - - const hostEnforcedProps: Record = { - ...createDropTargetGuardProps(reactBindableProps), - ...(isIframe && { - sandbox: sanitizeIframeSandbox(reactBindableProps.sandbox), - }), - // React 19 blocks the previous `action="javascript:void(0)"` guard. - ...(isForm && { - onSubmit: preventDefaultThenForwardToRemote( - reactBindableProps.onSubmit, - ), - }), - }; + const caretPreservingElementRef = useCaretPreservingElementRef( + composedElementRef, + reactBindableProps.value, + ); if ( - htmlTag === 'textarea' || - (htmlTag === 'input' && isTextLikeInputType(reactBindableProps.type)) + caretPreservingTag === 'textarea' || + isTextLikeInputType(reactBindableProps.type) ) { return createCaretPreservingElement({ - htmlTag, + htmlTag: caretPreservingTag, reactBindableProps, hostEnforcedProps, setEditableFocused, - reactUnsupportedEventListenerRef, + caretPreservingElementRef, }); } - return React.createElement( + return createPlainHostElement({ htmlTag, - { - ...reactBindableProps, - ...hostEnforcedProps, - ref: reactUnsupportedEventListenerRef, - }, - isVoid ? undefined : children, - ); + isVoid, + reactBindableProps, + hostEnforcedProps, + composedElementRef, + children, + }); }; }; diff --git a/packages/twenty-front-component-renderer/src/host/utils/createPlainHostElement.ts b/packages/twenty-front-component-renderer/src/host/utils/createPlainHostElement.ts new file mode 100644 index 0000000000..d84f7c311d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/createPlainHostElement.ts @@ -0,0 +1,30 @@ +import React from 'react'; + +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; + +type CreatePlainHostElementParams = { + htmlTag: string; + isVoid: boolean; + reactBindableProps: Record; + hostEnforcedProps: Record; + composedElementRef: ElementRefCallback; + children: React.ReactNode; +}; + +export const createPlainHostElement = ({ + htmlTag, + isVoid, + reactBindableProps, + hostEnforcedProps, + composedElementRef, + children, +}: CreatePlainHostElementParams) => + React.createElement( + htmlTag, + { + ...reactBindableProps, + ...hostEnforcedProps, + ref: composedElementRef, + }, + isVoid ? undefined : children, + );