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.
---
.../__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,
+ );