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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23262?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-24 14:09:40 +02:00
committed by GitHub
parent 3ee8fc0973
commit 923035bf48
13 changed files with 406 additions and 70 deletions
@@ -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);
});
});
@@ -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<Element | null>(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;
};
@@ -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;
};
@@ -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<string, unknown>;
hostEnforcedProps: Record<string, unknown>;
composedElementRef: ElementRefCallback;
};
export const useHtmlHostElementProps = (
props: Record<string, unknown>,
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<string, unknown> = {
...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,
};
};
@@ -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;
};
@@ -0,0 +1 @@
export type ElementRefCallback = (element: Element | null) => void;
@@ -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);
@@ -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');
@@ -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<string, unknown>) => {
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');
});
});
@@ -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<string, string> =
@@ -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<string, unknown>;
hostEnforcedProps: Record<string, unknown> | undefined;
hostEnforcedProps: Record<string, unknown>;
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,
});
};
@@ -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<string, unknown>;
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<string, unknown> = {
...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,
});
};
};
@@ -0,0 +1,30 @@
import React from 'react';
import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
type CreatePlainHostElementParams = {
htmlTag: string;
isVoid: boolean;
reactBindableProps: Record<string, unknown>;
hostEnforcedProps: Record<string, unknown>;
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,
);